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": {