Skip to content

Commit

Permalink
Feature: SMTP auto-forwarding option (#414)
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Jan 25, 2025
1 parent e2fab49 commit d7df895
Show file tree
Hide file tree
Showing 10 changed files with 522 additions and 238 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 23 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
264 changes: 40 additions & 224 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,17 @@ import (
"errors"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"

"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)

var (
Expand Down Expand Up @@ -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

Expand All @@ -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"

Expand Down Expand Up @@ -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 := "*"
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit d7df895

Please sign in to comment.