From 9f90a59befa08ac8f1556bc303a838658ca88219 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 13 Oct 2024 15:29:50 -0700 Subject: [PATCH] feat: Add SMTPSession and BeforeRcptToAccepted event (#541) Signed-off-by: James Hillyerd --- pkg/extension/event/events.go | 6 + pkg/extension/host.go | 9 +- pkg/extension/luahost/bind_inbucket.go | 9 +- pkg/extension/luahost/bind_smtpsession.go | 70 +++++++ .../luahost/bind_smtpsession_test.go | 38 ++++ pkg/extension/luahost/lua.go | 31 +++- pkg/extension/luahost/lua_test.go | 82 +++++++++ pkg/extension/luahost/pool.go | 1 + pkg/server/smtp/handler.go | 37 +++- pkg/server/smtp/handler_test.go | 172 +++++++++++++++++- 10 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 pkg/extension/luahost/bind_smtpsession.go create mode 100644 pkg/extension/luahost/bind_smtpsession_test.go diff --git a/pkg/extension/event/events.go b/pkg/extension/event/events.go index 14a7535..6b02803 100644 --- a/pkg/extension/event/events.go +++ b/pkg/extension/event/events.go @@ -47,3 +47,9 @@ type SMTPResponse struct { ErrorCode int // SMTP error code to respond with on deny. ErrorMsg string // SMTP error message to respond with on deny. } + +// SMTPSession captures SMTP `MAIL FROM` & `RCPT TO` values prior to mail DATA being received. +type SMTPSession struct { + From *mail.Address + To []*mail.Address +} diff --git a/pkg/extension/host.go b/pkg/extension/host.go index 38d68e8..c61d531 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -20,10 +20,11 @@ type Host struct { // processed asynchronously with respect to the rest of Inbuckets operation. However, an event // listener will not be called until the one before it completes. type Events struct { - AfterMessageDeleted AsyncEventBroker[event.MessageMetadata] - AfterMessageStored AsyncEventBroker[event.MessageMetadata] - BeforeMailAccepted EventBroker[event.AddressParts, event.SMTPResponse] - BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage] + AfterMessageDeleted AsyncEventBroker[event.MessageMetadata] + AfterMessageStored AsyncEventBroker[event.MessageMetadata] + BeforeMailAccepted EventBroker[event.AddressParts, event.SMTPResponse] + BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage] + BeforeRcptToAccepted EventBroker[event.SMTPSession, event.SMTPResponse] } // Void indicates the event emitter will ignore any value returned by listeners. diff --git a/pkg/extension/luahost/bind_inbucket.go b/pkg/extension/luahost/bind_inbucket.go index c641051..e01cfe0 100644 --- a/pkg/extension/luahost/bind_inbucket.go +++ b/pkg/extension/luahost/bind_inbucket.go @@ -29,8 +29,9 @@ type InbucketAfterFuncs struct { // InbucketBeforeFuncs holds references to Lua extension functions to be called // before Inbucket handles an event. type InbucketBeforeFuncs struct { - MailAccepted *lua.LFunction - MessageStored *lua.LFunction + MailAccepted *lua.LFunction + MessageStored *lua.LFunction + RcptToAccepted *lua.LFunction } func registerInbucketTypes(ls *lua.LState) { @@ -189,6 +190,8 @@ func inbucketBeforeIndex(ls *lua.LState) int { ls.Push(funcOrNil(before.MailAccepted)) case "message_stored": ls.Push(funcOrNil(before.MessageStored)) + case "rcpt_to_accepted": + ls.Push(funcOrNil(before.RcptToAccepted)) default: // Unknown field. ls.Push(lua.LNil) @@ -207,6 +210,8 @@ func inbucketBeforeNewIndex(ls *lua.LState) int { m.MailAccepted = ls.CheckFunction(3) case "message_stored": m.MessageStored = ls.CheckFunction(3) + case "rcpt_to_accepted": + m.RcptToAccepted = ls.CheckFunction(3) default: ls.RaiseError("invalid inbucket.before index %q", index) } diff --git a/pkg/extension/luahost/bind_smtpsession.go b/pkg/extension/luahost/bind_smtpsession.go new file mode 100644 index 0000000..b3cf504 --- /dev/null +++ b/pkg/extension/luahost/bind_smtpsession.go @@ -0,0 +1,70 @@ +package luahost + +import ( + "github.com/inbucket/inbucket/v3/pkg/extension/event" + lua "github.com/yuin/gopher-lua" +) + +const smtpSessionName = "smtp_session" + +func registerSMTPSessionType(ls *lua.LState) { + mt := ls.NewTypeMetatable(smtpSessionName) + ls.SetGlobal(smtpSessionName, mt) + + // Static attributes. + ls.SetField(mt, "new", ls.NewFunction(newSMTPSession)) + + // Methods. + ls.SetField(mt, "__index", ls.NewFunction(smtpSessionIndex)) +} + +func newSMTPSession(ls *lua.LState) int { + val := &event.SMTPSession{} + ud := wrapSMTPSession(ls, val) + ls.Push(ud) + + return 1 +} + +func wrapSMTPSession(ls *lua.LState, val *event.SMTPSession) *lua.LUserData { + ud := ls.NewUserData() + ud.Value = val + ls.SetMetatable(ud, ls.GetTypeMetatable(smtpSessionName)) + + return ud +} + +// Checks there is an SMTPSession at stack position `pos`, else throws Lua error. +func checkSMTPSession(ls *lua.LState, pos int) *event.SMTPSession { + ud := ls.CheckUserData(pos) + if v, ok := ud.Value.(*event.SMTPSession); ok { + return v + } + ls.ArgError(pos, smtpSessionName+" expected") + return nil +} + +// Gets a field value from SMTPSession user object. This emulates a Lua table, +// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`. +func smtpSessionIndex(ls *lua.LState) int { + m := checkSMTPSession(ls, 1) + field := ls.CheckString(2) + + // Push the requested field's value onto the stack. + switch field { + case "from": + ls.Push(wrapMailAddress(ls, m.From)) + case "to": + lt := &lua.LTable{} + for _, v := range m.To { + addr := v + lt.Append(wrapMailAddress(ls, addr)) + } + ls.Push(lt) + default: + // Unknown field. + ls.Push(lua.LNil) + } + + return 1 +} diff --git a/pkg/extension/luahost/bind_smtpsession_test.go b/pkg/extension/luahost/bind_smtpsession_test.go new file mode 100644 index 0000000..b495428 --- /dev/null +++ b/pkg/extension/luahost/bind_smtpsession_test.go @@ -0,0 +1,38 @@ +package luahost + +import ( + "net/mail" + "testing" + + "github.com/inbucket/inbucket/v3/pkg/extension/event" + "github.com/inbucket/inbucket/v3/pkg/test" + "github.com/stretchr/testify/require" +) + +func TestSMTPSessionGetters(t *testing.T) { + want := &event.SMTPSession{ + From: &mail.Address{Name: "name1", Address: "addr1"}, + To: []*mail.Address{ + {Name: "name2", Address: "addr2"}, + {Name: "name3", Address: "addr3"}, + }, + } + script := ` + assert(msg, "msg should not be nil") + + assert_eq(msg.from.name, "name1", "from.name") + assert_eq(msg.from.address, "addr1", "from.address") + + assert_eq(#msg.to, 2, "#msg.to") + assert_eq(msg.to[1].name, "name2", "to[1].name") + assert_eq(msg.to[1].address, "addr2", "to[1].address") + assert_eq(msg.to[2].name, "name3", "to[2].name") + assert_eq(msg.to[2].address, "addr3", "to[2].address") + ` + + ls, _ := test.NewLuaState() + registerSMTPSessionType(ls) + registerMailAddressType(ls) + ls.SetGlobal("msg", wrapSMTPSession(ls, want)) + require.NoError(t, ls.DoString(script)) +} diff --git a/pkg/extension/luahost/lua.go b/pkg/extension/luahost/lua.go index 8a49dd6..d0db001 100644 --- a/pkg/extension/luahost/lua.go +++ b/pkg/extension/luahost/lua.go @@ -112,6 +112,9 @@ func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) { if ib.Before.MessageStored != nil { events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored) } + if ib.Before.RcptToAccepted != nil { + events.BeforeRcptToAccepted.AddListener(listenerName, h.handleBeforeRcptToAccepted) + } } func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) { @@ -169,9 +172,33 @@ func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *event.SMTPResp ls.Pop(1) logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String()) - if lval.Type() == lua.LTNil || lua.LVIsFalse(lval) { + result, err := unwrapSMTPResponse(lval) + if err != nil { + logger.Error().Err(err).Msg("Bad response from Lua Function") + } + + return result +} + +func (h *Host) handleBeforeRcptToAccepted(session event.SMTPSession) *event.SMTPResponse { + logger, ls, ib, ok := h.prepareInbucketFuncCall("before.rcpt_to_accepted") + if !ok { return nil } + defer h.pool.putState(ls) + + logger.Debug().Msgf("Calling Lua function with %+v", session) + if err := ls.CallByParam( + lua.P{Fn: ib.Before.RcptToAccepted, NRet: 1, Protect: true}, + wrapSMTPSession(ls, &session), + ); err != nil { + logger.Error().Err(err).Msg("Failed to call Lua function") + return nil + } + + lval := ls.Get(-1) + ls.Pop(1) + logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String()) result, err := unwrapSMTPResponse(lval) if err != nil { @@ -201,7 +228,7 @@ func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.Inboun ls.Pop(1) logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String()) - if lval.Type() == lua.LTNil || lua.LVIsFalse(lval) { + if lua.LVIsFalse(lval) { return nil } diff --git a/pkg/extension/luahost/lua_test.go b/pkg/extension/luahost/lua_test.go index fe8bb54..84cd104 100644 --- a/pkg/extension/luahost/lua_test.go +++ b/pkg/extension/luahost/lua_test.go @@ -211,3 +211,85 @@ func TestBeforeMessageStored(t *testing.T) { } assert.Equal(t, want, got, "Response InboundMessage did not match") } + +func TestBeforeMessageStoredNilReturn(t *testing.T) { + // Event to send. + msg := event.InboundMessage{ + Mailboxes: []string{"one", "two"}, + From: &mail.Address{Name: "From Name", Address: "from@example.com"}, + To: []*mail.Address{ + {Name: "To1 Name", Address: "to1@example.com"}, + {Name: "To2 Name", Address: "to2@example.com"}, + }, + Subject: "inbound subj", + Size: 42, + } + + // Register lua event listener. + script := ` + async = true + + function inbucket.before.message_stored(msg) + assert(msg) + notify:send(asserts_ok) + + -- Generate response. + return nil + end + ` + extHost := extension.NewHost() + luaHost, err := luahost.NewFromReader(consoleLogger, extHost, + strings.NewReader(test.LuaInit+script), "test.lua") + require.NoError(t, err) + notify := luaHost.CreateChannel("notify") + + // Send event to be accepted. + got := extHost.Events.BeforeMessageStored.Emit(&msg) + require.Nil(t, got, "Expected nil result from Emit()") + + // Verify Lua assertions passed. + test.AssertNotified(t, notify) +} + +func TestBeforeRcptToAccepted(t *testing.T) { + // Event to send. + session := event.SMTPSession{ + From: &mail.Address{Name: "", Address: "from@example.com"}, + To: []*mail.Address{ + {Name: "", Address: "to1@example.com"}, + {Name: "", Address: "to2@example.com"}, + }, + } + + // Register lua event listener. + script := ` + async = true + + function inbucket.before.rcpt_to_accepted(msg) + -- Verify incoming values. + assert_eq(msg.from.address, "from@example.com") + assert_eq(2, #msg.to, "#msg.to") + assert_eq(msg.to[1].address, "to1@example.com") + assert_eq(msg.to[2].address, "to2@example.com") + notify:send(asserts_ok) + + return smtp.allow() + end + ` + extHost := extension.NewHost() + luaHost, err := luahost.NewFromReader(consoleLogger, extHost, + strings.NewReader(test.LuaInit+script), "test.lua") + require.NoError(t, err) + notify := luaHost.CreateChannel("notify") + + // Send event to be accepted. + got := extHost.Events.BeforeRcptToAccepted.Emit(&session) + require.NotNil(t, got, "Expected result from Emit()") + + // Verify Lua assertions passed. + test.AssertNotified(t, notify) + + // Verify response values. + want := event.SMTPResponse{Action: event.ActionAllow} + assert.Equal(t, want, *got) +} diff --git a/pkg/extension/luahost/pool.go b/pkg/extension/luahost/pool.go index 3ad48fd..ff700ab 100644 --- a/pkg/extension/luahost/pool.go +++ b/pkg/extension/luahost/pool.go @@ -49,6 +49,7 @@ func (lp *statePool) newState() (*lua.LState, error) { registerMailAddressType(ls) registerMessageMetadataType(ls) registerSMTPResponseType(ls) + registerSMTPSessionType(ls) // Run compiled script. ls.Push(ls.NewFunctionFromProto(lp.funcProto)) diff --git a/pkg/server/smtp/handler.go b/pkg/server/smtp/handler.go index 4fd4c05..ecbc62a 100644 --- a/pkg/server/smtp/handler.go +++ b/pkg/server/smtp/handler.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net" + "net/mail" "net/textproto" "regexp" "strconv" @@ -495,7 +496,26 @@ func (s *Session) mailHandler(cmd string, arg string) { s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg") return } - if !recip.ShouldAccept() { + + // Append new address to extSession for inspection. + addrCopy := recip.Address + extSession := s.extSession() + extSession.To = append(extSession.To, &addrCopy) + + // Process through extensions. + extAction := event.ActionDefer + extResult := s.extHost.Events.BeforeRcptToAccepted.Emit(extSession) + if extResult != nil { + extAction = extResult.Action + } + if extAction == event.ActionDeny { + s.send(fmt.Sprintf("%03d %s", extResult.ErrorCode, extResult.ErrorMsg)) + s.logger.Warn().Msgf("Extension denied mail to <%v>", recip.Address) + return + } + + // Ignore ShouldAccept if extensions explicitly allowed this Recipient. + if extAction == event.ActionDefer && !recip.ShouldAccept() { s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain") s.send("550 Relay not permitted") return @@ -687,3 +707,18 @@ func (s *Session) ooSeq(cmd string) { s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd)) s.logger.Warn().Msgf("Wasn't expecting %v here", cmd) } + +// extSession builds an SMTPSession for extensions. +func (s *Session) extSession() *event.SMTPSession { + from := s.from.Address + to := make([]*mail.Address, 0, len(s.recipients)) + for _, recip := range s.recipients { + addr := recip.Address + to = append(to, &addr) + } + + return &event.SMTPSession{ + From: &from, + To: to, + } +} diff --git a/pkg/server/smtp/handler_test.go b/pkg/server/smtp/handler_test.go index a0f9db6..e34f3d7 100644 --- a/pkg/server/smtp/handler_test.go +++ b/pkg/server/smtp/handler_test.go @@ -18,6 +18,7 @@ import ( "github.com/inbucket/inbucket/v3/pkg/test" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type scriptStep struct { @@ -457,7 +458,7 @@ func TestBeforeMailAcceptedEventResponse(t *testing.T) { // Play and verify SMTP session. script := []scriptStep{ {"HELO localhost", 250}, - tc.script, + tc.script, // error code is the significant part. {"QUIT", 221}} playSession(t, server, script) @@ -466,6 +467,175 @@ func TestBeforeMailAcceptedEventResponse(t *testing.T) { } } +// Tests "RCPT TO" emits BeforeRcptToAccepted event. +func TestBeforeRcptToAcceptedSingleEventEmitted(t *testing.T) { + ds := test.NewStore() + extHost := extension.NewHost() + server := setupSMTPServer(ds, extHost) + + var got *event.SMTPSession + extHost.Events.BeforeRcptToAccepted.AddListener( + "test", + func(session event.SMTPSession) *event.SMTPResponse { + got = &session + return &event.SMTPResponse{Action: event.ActionDefer} + }) + + // Play and verify SMTP session. + script := []scriptStep{ + {"HELO localhost", 250}, + {"MAIL FROM:", 250}, + {"RCPT TO:", 250}, + {"QUIT", 221}} + playSession(t, server, script) + + require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession") + require.NotNil(t, got.From) + require.NotNil(t, got.To) + assert.Equal(t, "john@gmail.com", got.From.Address) + assert.Len(t, got.To, 1) + assert.Equal(t, "user@gmail.com", got.To[0].Address) +} + +// Tests "RCPT TO" emits many BeforeRcptToAccepted events. +func TestBeforeRcptToAcceptedManyEventsEmitted(t *testing.T) { + ds := test.NewStore() + extHost := extension.NewHost() + server := setupSMTPServer(ds, extHost) + + var called int + var got *event.SMTPSession + extHost.Events.BeforeRcptToAccepted.AddListener( + "test", + func(session event.SMTPSession) *event.SMTPResponse { + called++ + got = &session + return &event.SMTPResponse{Action: event.ActionDefer} + }) + + // Play and verify SMTP session. + script := []scriptStep{ + {"HELO localhost", 250}, + {"MAIL FROM:", 250}, + {"RCPT TO:", 250}, + {"RCPT TO:", 250}, + {"QUIT", 221}} + playSession(t, server, script) + + require.Equal(t, 2, called, "2 events should have been emitted") + require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession") + require.NotNil(t, got.From) + require.NotNil(t, got.To) + assert.Equal(t, "john@gmail.com", got.From.Address) + assert.Len(t, got.To, 2) + assert.Equal(t, "user@gmail.com", got.To[0].Address) + assert.Equal(t, "user2@gmail.com", got.To[1].Address) +} + +// Tests we can continue after denying a "RCPT TO". +func TestBeforeRcptToAcceptedEventDeny(t *testing.T) { + ds := test.NewStore() + extHost := extension.NewHost() + server := setupSMTPServer(ds, extHost) + + var called int + var got *event.SMTPSession + extHost.Events.BeforeRcptToAccepted.AddListener( + "test", + func(session event.SMTPSession) *event.SMTPResponse { + called++ + + // Reject bad address. + action := event.ActionDefer + for _, to := range session.To { + if to.Address == "bad@apple.com" { + action = event.ActionDeny + } + } + + got = &session + return &event.SMTPResponse{Action: action, ErrorCode: 550, ErrorMsg: "rotten"} + }) + + // Play and verify SMTP session. + script := []scriptStep{ + {"HELO localhost", 250}, + {"MAIL FROM:", 250}, + {"RCPT TO:", 250}, + {"RCPT TO:", 550}, + {"RCPT TO:", 250}, + {"QUIT", 221}} + playSession(t, server, script) + + require.Equal(t, 3, called, "3 events should have been emitted") + require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession") + require.NotNil(t, got.From) + require.NotNil(t, got.To) + assert.Equal(t, "john@gmail.com", got.From.Address) + + // Verify bad apple dropped from final event. + assert.Len(t, got.To, 2) + assert.Equal(t, "user@gmail.com", got.To[0].Address) + assert.Equal(t, "user2@gmail.com", got.To[1].Address) +} + +// Test "RCPT TO" acts on BeforeRcptToAccepted event result. +func TestBeforeRcptToAcceptedEventResponse(t *testing.T) { + ds := test.NewStore() + extHost := extension.NewHost() + server := setupSMTPServer(ds, extHost) + + var shouldReturn *event.SMTPResponse + var gotEvent *event.SMTPSession + extHost.Events.BeforeRcptToAccepted.AddListener( + "test", + func(session event.SMTPSession) *event.SMTPResponse { + gotEvent = &session + return shouldReturn + }) + + tcs := map[string]struct { + script scriptStep // Command to send and SMTP code expected. + eventRes event.SMTPResponse // Response to send from event listener. + }{ + "allow": { + script: scriptStep{"RCPT TO:", 250}, + eventRes: event.SMTPResponse{Action: event.ActionAllow}, + }, + "deny": { + script: scriptStep{"RCPT TO:", 550}, + eventRes: event.SMTPResponse{ + Action: event.ActionDeny, + ErrorCode: 550, + ErrorMsg: "meh", + }, + }, + "defer": { + script: scriptStep{"RCPT TO:", 250}, + eventRes: event.SMTPResponse{Action: event.ActionDefer}, + }, + } + + for name, tc := range tcs { + tc := tc + t.Run(name, func(t *testing.T) { + // Reset event listener. + shouldReturn = &tc.eventRes + gotEvent = nil + + // Play and verify SMTP session. + script := []scriptStep{ + {"HELO localhost", 250}, + {"MAIL FROM:", 250}, + tc.script, // error code is the significant part. + {"QUIT", 221}} + playSession(t, server, script) + + assert.NotNil(t, gotEvent, "BeforeRcptToListener did not receive SMTPSession") + }) + } +} + // playSession creates a new session, reads the greeting and then plays the script func playSession(t *testing.T, server *Server, script []scriptStep) { t.Helper()