1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-04-17 12:06:22 +02:00

Feature: SMTP auto-forwarding option (#414)

This commit is contained in:
Ralph Slooten 2025-01-26 12:39:39 +13:00
parent e2fab49873
commit d7df895261
10 changed files with 524 additions and 240 deletions

View File

@ -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)

View File

@ -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")

View File

@ -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,6 +496,15 @@ func VerifyConfig() error {
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
return err
}
// separate forwarding config validation to account for environment variables
if err := validateForwardConfig(); err != nil {
return err
}
if DemoMode {
MaxMessages = 1000
// this deserves a warning
@ -487,213 +513,3 @@ func VerifyConfig() error {
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 {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); 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
}
}
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
}

51
config/utils.go Normal file
View File

@ -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
}

282
config/validators.go Normal file
View File

@ -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
}

111
internal/smtpd/forward.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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

View File

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