Repository: inbucket/inbucket
Pull request #541 (2024-10-13 22:03:41)

feat: Add Before RCPT TO Accepted event
- Add `SMTPSession` type for extensions
- Define & wire `BeforeRcptToAccepted` event
- Define `inbucket.before.rcpt_to_accepted` lua handler

coveralls: ## Pull Request Test Coverage Report for [Build 11318041791](https://coveralls.io/builds/70309949)


### Details

* **88** of **113**   **(77.88%)**  changed or added relevant lines in **5** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.5%**) to **56.698%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [pkg/extension/luahost/bind_inbucket.go](https://coveralls.io/builds/70309949/source?filename=pkg%2Fextension%2Fluahost%2Fbind_inbucket.go#L193) | 2 | 4 | 50.0%
| [pkg/extension/luahost/lua.go](https://coveralls.io/builds/70309949/source?filename=pkg%2Fextension%2Fluahost%2Flua.go#L177) | 20 | 25 | 80.0%
| [pkg/extension/luahost/bind_smtpsession.go](https://coveralls.io/builds/70309949/source?filename=pkg%2Fextension%2Fluahost%2Fbind_smtpsession.go#L23) | 38 | 56 | 67.86%
<!-- | **Total:** | **88** | **113** | **77.88%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/70309949/badge)](https://coveralls.io/builds/70309949) |
| :-- | --: |
| Change from base [Build 11197781004](https://coveralls.io/builds/70183598): |  0.5% |
| Covered Lines: | 2819 |
| Relevant Lines: | 4972 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
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/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/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,
+	}
+}


Test patch:
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_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/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:<john@gmail.com>", 250},
+		{"RCPT TO:<user@gmail.com>", 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:<john@gmail.com>", 250},
+		{"RCPT TO:<user@gmail.com>", 250},
+		{"RCPT TO:<user2@gmail.com>", 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:<john@gmail.com>", 250},
+		{"RCPT TO:<user@gmail.com>", 250},
+		{"RCPT TO:<bad@apple.com>", 550},
+		{"RCPT TO:<user2@gmail.com>", 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:<john@gmail.com>", 250},
+			eventRes: event.SMTPResponse{Action: event.ActionAllow},
+		},
+		"deny": {
+			script: scriptStep{"RCPT TO:<john@gmail.com>", 550},
+			eventRes: event.SMTPResponse{
+				Action:    event.ActionDeny,
+				ErrorCode: 550,
+				ErrorMsg:  "meh",
+			},
+		},
+		"defer": {
+			script:   scriptStep{"RCPT TO:<john@gmail.com>", 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:<user@gmail.com>", 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()