1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 09:37:02 +00:00

feat: Add SMTPSession and BeforeRcptToAccepted event (#541)

Signed-off-by: James Hillyerd <james@hillyerd.com>
This commit is contained in:
James Hillyerd
2024-10-13 15:29:50 -07:00
committed by GitHub
parent 3110183a17
commit 9f90a59bef
10 changed files with 445 additions and 10 deletions

View File

@@ -47,3 +47,9 @@ type SMTPResponse struct {
ErrorCode int // SMTP error code to respond with on deny. ErrorCode int // SMTP error code to respond with on deny.
ErrorMsg string // SMTP error message 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
}

View File

@@ -20,10 +20,11 @@ type Host struct {
// processed asynchronously with respect to the rest of Inbuckets operation. However, an event // processed asynchronously with respect to the rest of Inbuckets operation. However, an event
// listener will not be called until the one before it completes. // listener will not be called until the one before it completes.
type Events struct { type Events struct {
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata] AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
AfterMessageStored AsyncEventBroker[event.MessageMetadata] AfterMessageStored AsyncEventBroker[event.MessageMetadata]
BeforeMailAccepted EventBroker[event.AddressParts, event.SMTPResponse] BeforeMailAccepted EventBroker[event.AddressParts, event.SMTPResponse]
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage] BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
BeforeRcptToAccepted EventBroker[event.SMTPSession, event.SMTPResponse]
} }
// Void indicates the event emitter will ignore any value returned by listeners. // Void indicates the event emitter will ignore any value returned by listeners.

View File

@@ -29,8 +29,9 @@ type InbucketAfterFuncs struct {
// InbucketBeforeFuncs holds references to Lua extension functions to be called // InbucketBeforeFuncs holds references to Lua extension functions to be called
// before Inbucket handles an event. // before Inbucket handles an event.
type InbucketBeforeFuncs struct { type InbucketBeforeFuncs struct {
MailAccepted *lua.LFunction MailAccepted *lua.LFunction
MessageStored *lua.LFunction MessageStored *lua.LFunction
RcptToAccepted *lua.LFunction
} }
func registerInbucketTypes(ls *lua.LState) { func registerInbucketTypes(ls *lua.LState) {
@@ -189,6 +190,8 @@ func inbucketBeforeIndex(ls *lua.LState) int {
ls.Push(funcOrNil(before.MailAccepted)) ls.Push(funcOrNil(before.MailAccepted))
case "message_stored": case "message_stored":
ls.Push(funcOrNil(before.MessageStored)) ls.Push(funcOrNil(before.MessageStored))
case "rcpt_to_accepted":
ls.Push(funcOrNil(before.RcptToAccepted))
default: default:
// Unknown field. // Unknown field.
ls.Push(lua.LNil) ls.Push(lua.LNil)
@@ -207,6 +210,8 @@ func inbucketBeforeNewIndex(ls *lua.LState) int {
m.MailAccepted = ls.CheckFunction(3) m.MailAccepted = ls.CheckFunction(3)
case "message_stored": case "message_stored":
m.MessageStored = ls.CheckFunction(3) m.MessageStored = ls.CheckFunction(3)
case "rcpt_to_accepted":
m.RcptToAccepted = ls.CheckFunction(3)
default: default:
ls.RaiseError("invalid inbucket.before index %q", index) ls.RaiseError("invalid inbucket.before index %q", index)
} }

View File

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

View File

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

View File

@@ -112,6 +112,9 @@ func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
if ib.Before.MessageStored != nil { if ib.Before.MessageStored != nil {
events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored) events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored)
} }
if ib.Before.RcptToAccepted != nil {
events.BeforeRcptToAccepted.AddListener(listenerName, h.handleBeforeRcptToAccepted)
}
} }
func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) { func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) {
@@ -169,9 +172,33 @@ func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *event.SMTPResp
ls.Pop(1) ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String()) 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 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) result, err := unwrapSMTPResponse(lval)
if err != nil { if err != nil {
@@ -201,7 +228,7 @@ func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.Inboun
ls.Pop(1) ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String()) 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 return nil
} }

View File

@@ -211,3 +211,85 @@ func TestBeforeMessageStored(t *testing.T) {
} }
assert.Equal(t, want, got, "Response InboundMessage did not match") 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)
}

View File

@@ -49,6 +49,7 @@ func (lp *statePool) newState() (*lua.LState, error) {
registerMailAddressType(ls) registerMailAddressType(ls)
registerMessageMetadataType(ls) registerMessageMetadataType(ls)
registerSMTPResponseType(ls) registerSMTPResponseType(ls)
registerSMTPSessionType(ls)
// Run compiled script. // Run compiled script.
ls.Push(ls.NewFunctionFromProto(lp.funcProto)) ls.Push(ls.NewFunctionFromProto(lp.funcProto))

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"net/mail"
"net/textproto" "net/textproto"
"regexp" "regexp"
"strconv" "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") s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
return 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.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
s.send("550 Relay not permitted") s.send("550 Relay not permitted")
return return
@@ -687,3 +707,18 @@ func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd)) s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", 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,
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/test" "github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type scriptStep struct { type scriptStep struct {
@@ -457,7 +458,7 @@ func TestBeforeMailAcceptedEventResponse(t *testing.T) {
// Play and verify SMTP session. // Play and verify SMTP session.
script := []scriptStep{ script := []scriptStep{
{"HELO localhost", 250}, {"HELO localhost", 250},
tc.script, tc.script, // error code is the significant part.
{"QUIT", 221}} {"QUIT", 221}}
playSession(t, server, script) 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 // playSession creates a new session, reads the greeting and then plays the script
func playSession(t *testing.T, server *Server, script []scriptStep) { func playSession(t *testing.T, server *Server, script []scriptStep) {
t.Helper() t.Helper()