1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-13 20:04:49 +02:00

Feature: Allow rejected SMTP recipients to be silently dropped (#549)

This commit is contained in:
Matthias Gliwka
2025-08-10 10:34:26 +02:00
committed by GitHub
parent be95839838
commit 39d80df809
5 changed files with 232 additions and 34 deletions

View File

@@ -126,6 +126,7 @@ func init() {
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>") rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed") rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)") rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
rootCmd.Flags().BoolVar(&config.SMTPSilentlyDropRejectedRecipients, "smtp-silently-drop-rejected-recipients", config.SMTPSilentlyDropRejectedRecipients, "Accept emails to rejected recipients with 2xx response but silently drop them")
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups") rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
// SMTP relay // SMTP relay
@@ -301,6 +302,9 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 { if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS") config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
} }
if getEnabledFromEnv("MP_SMTP_SILENTLY_DROP_REJECTED_RECIPIENTS") {
config.SMTPSilentlyDropRejectedRecipients = true
}
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") { if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
smtpd.DisableReverseDNS = true smtpd.DisableReverseDNS = true
} }

View File

@@ -181,6 +181,9 @@ var (
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients // SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
SMTPAllowedRecipientsRegexp *regexp.Regexp SMTPAllowedRecipientsRegexp *regexp.Regexp
// SMTPSilentlyDropRejectedRecipients if true, will accept emails to rejected recipients with 2xx response but silently drop them
SMTPSilentlyDropRejectedRecipients bool
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address // POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
POP3Listen = "[::]:1110" POP3Listen = "[::]:1110"

View File

@@ -194,15 +194,16 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
Debug = true // to enable Mailpit logging Debug = true // to enable Mailpit logging
srv := &Server{ srv := &Server{
Addr: addr, Addr: addr,
MsgIDHandler: handler, MsgIDHandler: handler,
HandlerRcpt: handlerRcpt, HandlerRcpt: handlerRcpt,
AppName: "Mailpit", AppName: "Mailpit",
Hostname: "", Hostname: "",
AuthHandler: nil, AuthHandler: nil,
AuthRequired: false, AuthRequired: false,
MaxRecipients: config.SMTPMaxRecipients, MaxRecipients: config.SMTPMaxRecipients,
DisableReverseDNS: DisableReverseDNS, SilentlyDropRejectedRecipients: config.SMTPSilentlyDropRejectedRecipients,
DisableReverseDNS: DisableReverseDNS,
LogRead: func(remoteIP, verb, line string) { LogRead: func(remoteIP, verb, line string) {
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line) logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
}, },

View File

@@ -92,26 +92,27 @@ type LogFunc func(remoteIP, verb, line string)
// Server is an SMTP server. // Server is an SMTP server.
type Server struct { type Server struct {
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
AppName string AppName string
AuthHandler AuthHandler AuthHandler AuthHandler
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance. AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured. AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname
Handler Handler Handler Handler
HandlerRcpt HandlerRcpt HandlerRcpt HandlerRcpt
Hostname string Hostname string
LogRead LogFunc LogRead LogFunc
LogWrite LogFunc LogWrite LogFunc
MaxSize int // Maximum message size allowed, in bytes MaxSize int // Maximum message size allowed, in bytes
MaxRecipients int // Maximum number of recipients, defaults to 100. MaxRecipients int // Maximum number of recipients, defaults to 100.
MsgIDHandler MsgIDHandler MsgIDHandler MsgIDHandler
Timeout time.Duration SilentlyDropRejectedRecipients bool // Accept emails to rejected recipients with 2xx response but silently drop them
TLSConfig *tls.Config Timeout time.Duration
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured. TLSConfig *tls.Config
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured. TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
Protocol string // Default tcp, supports unix TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
SocketPerm fs.FileMode // if using Unix socket, socket permissions Protocol string // Default tcp, supports unix
SocketPerm fs.FileMode // if using Unix socket, socket permissions
inShutdown int32 // server was closed or shutdown inShutdown int32 // server was closed or shutdown
openSessions int32 // count of open sessions openSessions int32 // count of open sessions
@@ -363,6 +364,7 @@ func (s *session) serve() {
var from string var from string
var gotFrom bool var gotFrom bool
var to []string var to []string
var hasRejectedRecipients bool
var buffer bytes.Buffer var buffer bytes.Buffer
// RFC 5321 specifies support for minimum of 100 recipients is required. // RFC 5321 specifies support for minimum of 100 recipients is required.
@@ -397,6 +399,7 @@ loop:
from = "" from = ""
gotFrom = false gotFrom = false
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "EHLO": case "EHLO":
s.remoteName = args s.remoteName = args
@@ -406,6 +409,7 @@ loop:
from = "" from = ""
gotFrom = false gotFrom = false
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "MAIL": case "MAIL":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
@@ -457,6 +461,7 @@ loop:
} }
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "RCPT": case "RCPT":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
@@ -492,6 +497,9 @@ loop:
if accept { if accept {
to = append(to, match[1]) to = append(to, match[1])
s.writef("250 2.1.5 Ok") s.writef("250 2.1.5 Ok")
} else if s.srv.SilentlyDropRejectedRecipients {
hasRejectedRecipients = true
s.writef("250 2.1.5 Ok")
} else { } else {
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable") s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
} }
@@ -506,7 +514,8 @@ loop:
s.writef("530 5.7.0 Authentication required") s.writef("530 5.7.0 Authentication required")
break break
} }
if !gotFrom || len(to) == 0 { hasRecipients := len(to) > 0 || hasRejectedRecipients
if !gotFrom || !hasRecipients {
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)") s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
break break
} }
@@ -536,11 +545,13 @@ loop:
// Create Received header & write message body into buffer. // Create Received header & write message body into buffer.
buffer.Reset() buffer.Reset()
buffer.Write(s.makeHeaders(to)) if len(to) > 0 {
buffer.Write(s.makeHeaders(to))
}
buffer.Write(data) buffer.Write(data)
// Pass mail on to handler. // Pass mail on to handler only if there are valid recipients.
if s.srv.Handler != nil { if len(to) > 0 && s.srv.Handler != nil {
err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes()) err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
if err != nil { if err != nil {
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`) checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
@@ -552,7 +563,7 @@ loop:
break break
} }
s.writef("250 2.0.0 Ok: queued") s.writef("250 2.0.0 Ok: queued")
} else if s.srv.MsgIDHandler != nil { } else if len(to) > 0 && s.srv.MsgIDHandler != nil {
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username) msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username)
if err != nil { if err != nil {
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`) checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
@@ -570,6 +581,13 @@ loop:
s.writef("250 2.0.0 Ok: queued") s.writef("250 2.0.0 Ok: queued")
} }
} else { } else {
if hasRejectedRecipients && Debug {
if s.srv.LogWrite != nil {
s.srv.LogWrite(s.remoteIP, "DEBUG", "Message from sender silently dropped (rejected recipients)")
} else {
log.Printf("%s DEBUG Message from sender silently dropped (rejected recipients)", s.remoteIP)
}
}
s.writef("250 2.0.0 Ok: queued") s.writef("250 2.0.0 Ok: queued")
} }
@@ -577,6 +595,7 @@ loop:
from = "" from = ""
gotFrom = false gotFrom = false
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "QUIT": case "QUIT":
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName) s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName)
@@ -590,6 +609,7 @@ loop:
from = "" from = ""
gotFrom = false gotFrom = false
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "NOOP": case "NOOP":
s.writef("250 2.0.0 Ok") s.writef("250 2.0.0 Ok")
@@ -666,6 +686,7 @@ loop:
from = "" from = ""
gotFrom = false gotFrom = false
to = nil to = nil
hasRejectedRecipients = false
buffer.Reset() buffer.Reset()
case "AUTH": case "AUTH":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {

View File

@@ -1598,3 +1598,172 @@ func TestCmdShutdown(t *testing.T) {
_ = conn.Close() _ = conn.Close()
} }
type mockDropRejectedHandler struct {
handlerCalled int
lastFrom string
lastTo []string
msgIDCalled int
lastMsgIDFrom string
lastMsgIDTo []string
}
func (m *mockDropRejectedHandler) handler(remoteAddr net.Addr, from string, to []string, data []byte) error {
m.handlerCalled++
m.lastFrom = from
m.lastTo = append([]string{}, to...) // copy slice
return nil
}
func (m *mockDropRejectedHandler) msgIDHandler(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) {
m.msgIDCalled++
m.lastMsgIDFrom = from
m.lastMsgIDTo = append([]string{}, to...) // copy slice
return "test-message-id", nil
}
// Test the SilentlyDropRejectedRecipients option
func TestSilentlyDropRejectedRecipients(t *testing.T) {
tests := []struct {
name string
silentlyDropRejectedRecipients bool
handlerRcpt func(net.Addr, string, string) bool
rcptCommands []struct{ addr, expectedCode string }
expectedHandlerCalls int
expectedHandlerRecipients []string
useMsgIDHandler bool
}{
{
name: "Disabled_DefaultBehavior",
silentlyDropRejectedRecipients: false,
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
return !strings.HasSuffix(to, "@rejected.com")
},
rcptCommands: []struct{ addr, expectedCode string }{
{"valid@example.com", "250"},
{"invalid@rejected.com", "550"},
},
expectedHandlerCalls: 1,
expectedHandlerRecipients: []string{"valid@example.com"},
},
{
name: "Enabled_MixedRecipients",
silentlyDropRejectedRecipients: true,
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
return !strings.HasSuffix(to, "@rejected.com")
},
rcptCommands: []struct{ addr, expectedCode string }{
{"valid1@example.com", "250"},
{"valid2@example.com", "250"},
{"invalid1@rejected.com", "250"}, // Now accepted but dropped
{"invalid2@rejected.com", "250"}, // Now accepted but dropped
},
expectedHandlerCalls: 1,
expectedHandlerRecipients: []string{"valid1@example.com", "valid2@example.com"},
},
{
name: "Enabled_AllRejected",
silentlyDropRejectedRecipients: true,
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
return false // Reject all
},
rcptCommands: []struct{ addr, expectedCode string }{
{"test1@example.com", "250"}, // Accepted but dropped
{"test2@example.com", "250"}, // Accepted but dropped
},
expectedHandlerCalls: 0, // No handler calls since all rejected
expectedHandlerRecipients: nil,
},
{
name: "Enabled_OnlyValid",
silentlyDropRejectedRecipients: true,
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
return strings.HasSuffix(to, "@valid.com")
},
rcptCommands: []struct{ addr, expectedCode string }{
{"user1@valid.com", "250"},
{"user2@valid.com", "250"},
{"user3@valid.com", "250"},
},
expectedHandlerCalls: 1,
expectedHandlerRecipients: []string{"user1@valid.com", "user2@valid.com", "user3@valid.com"},
},
{
name: "Enabled_WithMsgIDHandler",
silentlyDropRejectedRecipients: true,
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
return !strings.HasSuffix(to, "@rejected.com")
},
rcptCommands: []struct{ addr, expectedCode string }{
{"valid@example.com", "250"},
{"invalid@rejected.com", "250"}, // Accepted but dropped
},
expectedHandlerCalls: 1,
expectedHandlerRecipients: []string{"valid@example.com"},
useMsgIDHandler: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockDropRejectedHandler{}
server := &Server{
Hostname: "mail.example.com",
AppName: "TestMail",
MaxRecipients: 100,
HandlerRcpt: tt.handlerRcpt,
SilentlyDropRejectedRecipients: tt.silentlyDropRejectedRecipients,
}
if tt.useMsgIDHandler {
server.MsgIDHandler = mock.msgIDHandler
} else {
server.Handler = mock.handler
}
conn := newConn(t, server)
defer func() { _ = conn.Close() }()
cmdCode(t, conn, "HELO host.example.com", "250")
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
// Send RCPT commands
for _, rcpt := range tt.rcptCommands {
cmdCode(t, conn, "RCPT TO:<"+rcpt.addr+">", rcpt.expectedCode)
}
// Send DATA
cmdCode(t, conn, "DATA", "354")
cmdCode(t, conn, "Subject: Test\r\n\r\nTest message\r\n.", "250")
cmdCode(t, conn, "QUIT", "221")
// Verify handler calls
if tt.useMsgIDHandler {
if mock.msgIDCalled != tt.expectedHandlerCalls {
t.Errorf("Expected %d MsgIDHandler calls, got %d", tt.expectedHandlerCalls, mock.msgIDCalled)
}
if tt.expectedHandlerCalls > 0 {
if mock.lastMsgIDFrom != "sender@example.com" {
t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastMsgIDFrom)
}
if !reflect.DeepEqual(mock.lastMsgIDTo, tt.expectedHandlerRecipients) {
t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastMsgIDTo)
}
}
} else {
if mock.handlerCalled != tt.expectedHandlerCalls {
t.Errorf("Expected %d handler calls, got %d", tt.expectedHandlerCalls, mock.handlerCalled)
}
if tt.expectedHandlerCalls > 0 {
if mock.lastFrom != "sender@example.com" {
t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastFrom)
}
if !reflect.DeepEqual(mock.lastTo, tt.expectedHandlerRecipients) {
t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastTo)
}
}
}
})
}
}