mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-10-29 13:42:30 +00:00
feat(luahost): Lua-controlled settings and decoded header access (#630)
* feat(luahost): capture config settings set by Lua scripts Add inbucket.config, a Lua-settable settings namespace, with its first field decode_headers registering additional message headers for Inbucket to decode. Values are captured once at startup from the first pooled LState and exposed to the assembly via Host.ScriptConfig(). Settings that only have value to the Lua script belong here rather than in env config. No consumers of decode_headers yet; that follows. * feat(message): expose decoded headers to extensions StoreManager gains ExtraDecodeHeaders, passed through to enmime.DecodeHeaders, and the decoded header set (defaults plus any registered extras) is attached to the InboundMessage event as Headers (net/textproto.MIMEHeader). Lua scripts access it via the read-only inbound_message.header field, added with the new inbound_header binding. Lookups are exact-then- case-insensitive, since enmime canonicalizes keys with email-specific rules that differ from net/textproto. FullAssembly wires ScriptConfig().DecodeHeaders into StoreManager. Part of #627. * docs(extension): document shared and untrusted header semantics Reviewer note: BeforeMessageStored listeners receive the same decoded header map, so a Go listener's mutations are visible to later listeners. Also record that sender-supplied header values (Authentication-Results, X-Spam-Status) are untrusted unless the deployment controls provenance. Part of #627. --------- Co-authored-by: hillyerd-bot <hillyerd-bot@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Lua scripts can register extra message headers to decode via the new
|
||||||
|
`inbucket.config.decode_headers` setting, and read them from the new
|
||||||
|
`inbound_message.header` field (#627)
|
||||||
|
|
||||||
|
|
||||||
## [v3.2.0] - 2026-10-10
|
## [v3.2.0] - 2026-10-10
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package event
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,16 @@ type InboundMessage struct {
|
|||||||
To []*mail.Address
|
To []*mail.Address
|
||||||
Subject string
|
Subject string
|
||||||
Size int64
|
Size int64
|
||||||
|
// Headers contains the decoded message headers: the default set (Date,
|
||||||
|
// Subject, Sender, From, To, CC, BCC) plus any additional names registered
|
||||||
|
// by a Lua script via inbucket.config.decode_headers.
|
||||||
|
//
|
||||||
|
// The same map is shared with every BeforeMessageStored listener; mutations
|
||||||
|
// made by a Go listener are visible to listeners that run after it. Sender
|
||||||
|
// supplied values are untrusted: headers such as Authentication-Results and
|
||||||
|
// X-Spam-Status should only drive decisions when the deployment controls
|
||||||
|
// their provenance.
|
||||||
|
Headers textproto.MIMEHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageMetadata contains the basic header data for a message event.
|
// MessageMetadata contains the basic header data for a message event.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inbucketConfigName = "inbucket_config"
|
||||||
|
|
||||||
|
// InbucketConfig holds Inbucket settings that a Lua script may control.
|
||||||
|
//
|
||||||
|
// Values are captured once, from the first LState after the script runs. The
|
||||||
|
// script is re-run for each additional pooled LState, but its later config
|
||||||
|
// settings are ignored: scripts must set config deterministically at the top
|
||||||
|
// level.
|
||||||
|
type InbucketConfig struct {
|
||||||
|
// DecodeHeaders names additional message headers to decode; the default
|
||||||
|
// set (Date, Subject, Sender, From, To, CC, BCC) is always decoded.
|
||||||
|
// Decoded headers are exposed to scripts via the `inbound_message.header`
|
||||||
|
// field. Only headers listed here and in the default set are decoded,
|
||||||
|
// keeping the cost proportional to what the script asked for.
|
||||||
|
DecodeHeaders []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerInbucketConfigType(ls *lua.LState) {
|
||||||
|
// inbucket.config type.
|
||||||
|
mt := ls.NewTypeMetatable(inbucketConfigName)
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(inbucketConfigIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketConfigNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInbucketConfig(ls *lua.LState, val *InbucketConfig) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketConfigName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInbucketConfig(ls *lua.LState, pos int) *InbucketConfig {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if val, ok := ud.Value.(*InbucketConfig); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
ls.ArgError(pos, inbucketConfigName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.config getter.
|
||||||
|
func inbucketConfigIndex(ls *lua.LState) int {
|
||||||
|
c := checkInbucketConfig(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "decode_headers":
|
||||||
|
if len(c.DecodeHeaders) == 0 {
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
lt := &lua.LTable{}
|
||||||
|
for _, v := range c.DecodeHeaders {
|
||||||
|
lt.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
ls.Push(lt)
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.config setter.
|
||||||
|
func inbucketConfigNewIndex(ls *lua.LState) int {
|
||||||
|
c := checkInbucketConfig(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "decode_headers":
|
||||||
|
lt := ls.CheckTable(3)
|
||||||
|
headers := make([]string, 0, lt.Len())
|
||||||
|
for i := 1; i <= lt.Len(); i++ {
|
||||||
|
value := lt.RawGetInt(i)
|
||||||
|
if value.Type() != lua.LTString {
|
||||||
|
ls.RaiseError("decode_headers[%d] must be a string, got %s", i, value.Type().String())
|
||||||
|
}
|
||||||
|
headers = append(headers, value.String())
|
||||||
|
}
|
||||||
|
c.DecodeHeaders = headers
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid inbucket.config index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInbucketConfigDecodeHeaders(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
assert(inbucket, "inbucket should not be nil")
|
||||||
|
assert(inbucket.config, "inbucket.config should not be nil")
|
||||||
|
|
||||||
|
-- Verify decode_headers starts off nil.
|
||||||
|
assert(inbucket.config.decode_headers == nil,
|
||||||
|
"decode_headers should be nil")
|
||||||
|
|
||||||
|
-- Set and read back.
|
||||||
|
inbucket.config.decode_headers = {
|
||||||
|
"X-Spam-Status",
|
||||||
|
"Authentication-Results",
|
||||||
|
}
|
||||||
|
assert_eq(inbucket.config.decode_headers,
|
||||||
|
{ "X-Spam-Status", "Authentication-Results" })
|
||||||
|
|
||||||
|
-- Unknown fields read as nil.
|
||||||
|
assert(inbucket.config.bogus == nil, "unknown config field should be nil")
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
|
||||||
|
ib, err := getInbucket(ls)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"X-Spam-Status", "Authentication-Results"},
|
||||||
|
ib.Config.DecodeHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInbucketConfigDecodeHeadersEmpty(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
inbucket.config.decode_headers = {}
|
||||||
|
assert(inbucket.config.decode_headers == nil,
|
||||||
|
"empty decode_headers should read as nil")
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
|
||||||
|
ib, err := getInbucket(ls)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, ib.Config.DecodeHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInbucketConfigInvalidIndex(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
inbucket.config.bogus = true
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
err := ls.DoString(script)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid inbucket.config index")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInbucketConfigDecodeHeadersInvalidEntry(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
inbucket.config.decode_headers = { "X-Spam-Status", 42 }
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
err := ls.DoString(script)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), `decode_headers[2] must be a string`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerName = "inbound_header"
|
||||||
|
|
||||||
|
func registerHeaderType(ls *lua.LState) {
|
||||||
|
// header type.
|
||||||
|
mt := ls.NewTypeMetatable(headerName)
|
||||||
|
|
||||||
|
// Methods.
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(headerIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(headerNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapHeader(ls *lua.LState, val textproto.MIMEHeader) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(headerName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks there is a MIMEHeader at stack position `pos`, else throws Lua error.
|
||||||
|
func checkHeader(ls *lua.LState, pos int) textproto.MIMEHeader {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if v, ok := ud.Value.(textproto.MIMEHeader); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
ls.ArgError(pos, headerName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets a header value by name. Header names are case-insensitive; the first
|
||||||
|
// value is returned when a header was set more than once, and nil when the
|
||||||
|
// header is absent or unset. This emulates a Lua table, allowing
|
||||||
|
// `msg.header["x-spam-status"]`.
|
||||||
|
func headerIndex(ls *lua.LState) int {
|
||||||
|
h := checkHeader(ls, 1)
|
||||||
|
name := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Exact match first, then case-insensitive. Decoded keys are canonicalized
|
||||||
|
// by enmime using email-specific rules that differ from net/textproto, so
|
||||||
|
// comparing against the stored keys is more reliable than canonicalizing
|
||||||
|
// the requested name. The header set is small, a scan is cheap.
|
||||||
|
if values, ok := h[name]; ok && len(values) > 0 {
|
||||||
|
ls.Push(lua.LString(values[0]))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for key, values := range h {
|
||||||
|
if len(values) > 0 && strings.EqualFold(key, name) {
|
||||||
|
ls.Push(lua.LString(values[0]))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header absent, or registered but unset on this message.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejects writes; headers are read-only.
|
||||||
|
func headerNewIndex(ls *lua.LState) int {
|
||||||
|
checkHeader(ls, 1)
|
||||||
|
name := ls.CheckString(2)
|
||||||
|
ls.RaiseError("header %q is read-only", name)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/textproto"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeaderGetters(t *testing.T) {
|
||||||
|
header := textproto.MIMEHeader{
|
||||||
|
"Subject": {"subj1"},
|
||||||
|
"From": {"from@example.com"},
|
||||||
|
"X-Spam-Status": {"Yes", "second value ignored"},
|
||||||
|
"X-Exotic+Key.Keep": {"exotic1"},
|
||||||
|
"X-Registered-But-Unset": {},
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(header, "header should not be nil")
|
||||||
|
|
||||||
|
-- Subject matches msg.subject.
|
||||||
|
assert_eq(header.subject, "subj1")
|
||||||
|
assert_eq(header.Subject, "subj1")
|
||||||
|
assert_eq(header["SUBJECT"], "subj1")
|
||||||
|
|
||||||
|
-- Registered extra header, case-insensitive.
|
||||||
|
assert_eq(header["X-Spam-Status"], "Yes")
|
||||||
|
assert_eq(header["x-spam-status"], "Yes")
|
||||||
|
|
||||||
|
-- First value wins when set more than once.
|
||||||
|
assert_eq(header.from, "from@example.com")
|
||||||
|
|
||||||
|
-- Keys enmime's canonicalization rules preserve exactly.
|
||||||
|
assert_eq(header["X-Exotic+Key.Keep"], "exotic1")
|
||||||
|
assert_eq(header["x-exotic+key.keep"], "exotic1")
|
||||||
|
|
||||||
|
-- Registered but unset, and unregistered headers, are nil.
|
||||||
|
assert(header["X-Registered-But-Unset"] == nil,
|
||||||
|
"unset header should be nil")
|
||||||
|
assert(header["X-Not-Registered"] == nil,
|
||||||
|
"unregistered header should be nil")
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerHeaderType(ls)
|
||||||
|
ls.SetGlobal("header", wrapHeader(ls, header))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderReadOnly(t *testing.T) {
|
||||||
|
header := textproto.MIMEHeader{"Subject": {"subj1"}}
|
||||||
|
script := `
|
||||||
|
header["Subject"] = "rewritten"
|
||||||
|
`
|
||||||
|
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerHeaderType(ls)
|
||||||
|
ls.SetGlobal("header", wrapHeader(ls, header))
|
||||||
|
err := ls.DoString(script)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), `header "Subject" is read-only`)
|
||||||
|
|
||||||
|
// Value was not modified.
|
||||||
|
assert.Equal(t, []string{"subj1"}, header["Subject"])
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ func inboundMessageIndex(ls *lua.LState) int {
|
|||||||
ls.Push(lua.LString(m.Subject))
|
ls.Push(lua.LString(m.Subject))
|
||||||
case "size":
|
case "size":
|
||||||
ls.Push(lua.LNumber(m.Size))
|
ls.Push(lua.LNumber(m.Size))
|
||||||
|
case "header":
|
||||||
|
ls.Push(wrapHeader(ls, m.Headers))
|
||||||
default:
|
default:
|
||||||
// Unknown field.
|
// Unknown field.
|
||||||
ls.Push(lua.LNil)
|
ls.Push(lua.LNil)
|
||||||
@@ -127,6 +129,8 @@ func inboundMessageNewIndex(ls *lua.LState) int {
|
|||||||
m.Subject = ls.CheckString(3)
|
m.Subject = ls.CheckString(3)
|
||||||
case "size":
|
case "size":
|
||||||
ls.RaiseError("size is read-only")
|
ls.RaiseError("size is read-only")
|
||||||
|
case "header":
|
||||||
|
ls.RaiseError("header is read-only")
|
||||||
default:
|
default:
|
||||||
ls.RaiseError("invalid index %q", index)
|
ls.RaiseError("invalid index %q", index)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package luahost
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
@@ -20,6 +21,10 @@ func TestInboundMessageGetters(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Subject: "subj1",
|
Subject: "subj1",
|
||||||
Size: 42,
|
Size: 42,
|
||||||
|
Headers: textproto.MIMEHeader{
|
||||||
|
"Subject": {"subj1"},
|
||||||
|
"X-Spam-Status": {"Yes"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
script := `
|
script := `
|
||||||
assert(msg, "msg should not be nil")
|
assert(msg, "msg should not be nil")
|
||||||
@@ -28,6 +33,10 @@ func TestInboundMessageGetters(t *testing.T) {
|
|||||||
assert_eq(msg.subject, "subj1")
|
assert_eq(msg.subject, "subj1")
|
||||||
assert_eq(msg.size, 42, "msg.size")
|
assert_eq(msg.size, 42, "msg.size")
|
||||||
|
|
||||||
|
assert_eq(msg.header.subject, "subj1")
|
||||||
|
assert_eq(msg.header["x-spam-status"], "Yes")
|
||||||
|
assert(msg.header["x-not-registered"] == nil, "unregistered header should be nil")
|
||||||
|
|
||||||
assert_eq(msg.from.name, "name1", "from.name")
|
assert_eq(msg.from.name, "name1", "from.name")
|
||||||
assert_eq(msg.from.address, "addr1", "from.address")
|
assert_eq(msg.from.address, "addr1", "from.address")
|
||||||
|
|
||||||
@@ -41,10 +50,26 @@ func TestInboundMessageGetters(t *testing.T) {
|
|||||||
ls, _ := test.NewLuaState()
|
ls, _ := test.NewLuaState()
|
||||||
registerInboundMessageType(ls)
|
registerInboundMessageType(ls)
|
||||||
registerMailAddressType(ls)
|
registerMailAddressType(ls)
|
||||||
|
registerHeaderType(ls)
|
||||||
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
|
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
|
||||||
require.NoError(t, ls.DoString(script))
|
require.NoError(t, ls.DoString(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInboundMessageHeaderReadOnly(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
msg.header = {}
|
||||||
|
`
|
||||||
|
|
||||||
|
got := &event.InboundMessage{}
|
||||||
|
ls, _ := test.NewLuaState()
|
||||||
|
registerInboundMessageType(ls)
|
||||||
|
registerHeaderType(ls)
|
||||||
|
ls.SetGlobal("msg", wrapInboundMessage(ls, got))
|
||||||
|
err := ls.DoString(script)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "header is read-only")
|
||||||
|
}
|
||||||
|
|
||||||
func TestInboundMessageSetters(t *testing.T) {
|
func TestInboundMessageSetters(t *testing.T) {
|
||||||
want := &event.InboundMessage{
|
want := &event.InboundMessage{
|
||||||
Mailboxes: []string{"mb1", "mb2"},
|
Mailboxes: []string{"mb1", "mb2"},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
|
|
||||||
// Inbucket is the primary Lua interface data structure.
|
// Inbucket is the primary Lua interface data structure.
|
||||||
type Inbucket struct {
|
type Inbucket struct {
|
||||||
|
Config InbucketConfig
|
||||||
After InbucketAfterFuncs
|
After InbucketAfterFuncs
|
||||||
Before InbucketBeforeFuncs
|
Before InbucketBeforeFuncs
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,9 @@ func registerInbucketTypes(ls *lua.LState) {
|
|||||||
ud := wrapInbucket(ls, &Inbucket{})
|
ud := wrapInbucket(ls, &Inbucket{})
|
||||||
ls.SetGlobal(inbucketName, ud)
|
ls.SetGlobal(inbucketName, ud)
|
||||||
|
|
||||||
|
// inbucket.config type.
|
||||||
|
registerInbucketConfigType(ls)
|
||||||
|
|
||||||
// inbucket.after type.
|
// inbucket.after type.
|
||||||
mt = ls.NewTypeMetatable(inbucketAfterName)
|
mt = ls.NewTypeMetatable(inbucketAfterName)
|
||||||
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
|
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
|
||||||
@@ -131,6 +135,8 @@ func inbucketIndex(ls *lua.LState) int {
|
|||||||
|
|
||||||
// Push the requested field's value onto the stack.
|
// Push the requested field's value onto the stack.
|
||||||
switch field {
|
switch field {
|
||||||
|
case "config":
|
||||||
|
ls.Push(wrapInbucketConfig(ls, &ib.Config))
|
||||||
case "after":
|
case "after":
|
||||||
ls.Push(wrapInbucketAfter(ls, &ib.After))
|
ls.Push(wrapInbucketAfter(ls, &ib.After))
|
||||||
case "before":
|
case "before":
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ var ErrNoScript error = errors.New("no script file present")
|
|||||||
|
|
||||||
// Host of Lua extensions.
|
// Host of Lua extensions.
|
||||||
type Host struct {
|
type Host struct {
|
||||||
extHost *extension.Host
|
extHost *extension.Host
|
||||||
pool *statePool
|
pool *statePool
|
||||||
logContext zerolog.Context
|
logContext zerolog.Context
|
||||||
|
scriptConfig InbucketConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a new Lua Host, pre-compiling the source.
|
// New constructs a new Lua Host, pre-compiling the source.
|
||||||
@@ -74,6 +75,7 @@ func NewFromReader(logger zerolog.Logger, extHost *extension.Host, r io.Reader,
|
|||||||
h := &Host{extHost: extHost, pool: pool, logContext: logger.With()}
|
h := &Host{extHost: extHost, pool: pool, logContext: logger.With()}
|
||||||
if ls, err := pool.getState(); err == nil {
|
if ls, err := pool.getState(); err == nil {
|
||||||
h.wireFunctions(startLogger, ls)
|
h.wireFunctions(startLogger, ls)
|
||||||
|
h.readScriptConfig(startLogger, ls)
|
||||||
|
|
||||||
// State creation works, put it back.
|
// State creation works, put it back.
|
||||||
pool.putState(ls)
|
pool.putState(ls)
|
||||||
@@ -90,6 +92,28 @@ func (h *Host) CreateChannel(name string) chan lua.LValue {
|
|||||||
return h.pool.createChannel(name)
|
return h.pool.createChannel(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScriptConfig returns the Inbucket settings set by the Lua script at startup.
|
||||||
|
func (h *Host) ScriptConfig() InbucketConfig {
|
||||||
|
return h.scriptConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// readScriptConfig captures the settings the script set on `inbucket.config`.
|
||||||
|
// The script is re-run for each additional pooled LState, but config is only
|
||||||
|
// captured here, from the first; scripts must set it deterministically.
|
||||||
|
func (h *Host) readScriptConfig(logger zerolog.Logger, ls *lua.LState) {
|
||||||
|
ib, err := getInbucket(ls)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to get inbucket global")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.scriptConfig = ib.Config
|
||||||
|
if len(h.scriptConfig.DecodeHeaders) > 0 {
|
||||||
|
logger.Info().Strs("headers", h.scriptConfig.DecodeHeaders).
|
||||||
|
Msg("Captured Lua script config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detects global lua event listener functions and wires them up.
|
// Detects global lua event listener functions and wires them up.
|
||||||
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
|
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
|
||||||
ib, err := getInbucket(ls)
|
ib, err := getInbucket(ls)
|
||||||
|
|||||||
@@ -41,6 +41,33 @@ func TestLogger(t *testing.T) {
|
|||||||
assert.Contains(t, output.String(), "_test log entry_")
|
assert.Contains(t, output.String(), "_test log entry_")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptConfigDecodeHeaders(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
inbucket.config.decode_headers = {
|
||||||
|
"X-Spam-Status",
|
||||||
|
"Authentication-Results",
|
||||||
|
}
|
||||||
|
`
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
|
||||||
|
strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"X-Spam-Status", "Authentication-Results"},
|
||||||
|
luaHost.ScriptConfig().DecodeHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScriptConfigDefault(t *testing.T) {
|
||||||
|
script := ``
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
|
||||||
|
strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Empty(t, luaHost.ScriptConfig().DecodeHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAfterMessageDeleted(t *testing.T) {
|
func TestAfterMessageDeleted(t *testing.T) {
|
||||||
// Register lua event listener, setup notify channel.
|
// Register lua event listener, setup notify channel.
|
||||||
script := `
|
script := `
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (lp *statePool) newState() (*lua.LState, error) {
|
|||||||
registerInbucketTypes(ls)
|
registerInbucketTypes(ls)
|
||||||
registerMailAddressType(ls)
|
registerMailAddressType(ls)
|
||||||
registerMessageMetadataType(ls)
|
registerMessageMetadataType(ls)
|
||||||
|
registerHeaderType(ls)
|
||||||
registerSMTPResponseType(ls)
|
registerSMTPResponseType(ls)
|
||||||
registerSMTPSessionType(ls)
|
registerSMTPSessionType(ls)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,6 +42,9 @@ type StoreManager struct {
|
|||||||
AddrPolicy *policy.Addressing
|
AddrPolicy *policy.Addressing
|
||||||
Store storage.Store
|
Store storage.Store
|
||||||
ExtHost *extension.Host
|
ExtHost *extension.Host
|
||||||
|
// ExtraDecodeHeaders names message headers beyond the default set to
|
||||||
|
// decode and expose to extensions via event.InboundMessage.Headers.
|
||||||
|
ExtraDecodeHeaders []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver submits a new message to the store.
|
// Deliver submits a new message to the store.
|
||||||
@@ -53,7 +57,7 @@ func (s *StoreManager) Deliver(
|
|||||||
logger := log.With().Str("module", "message").Logger()
|
logger := log.With().Str("module", "message").Logger()
|
||||||
|
|
||||||
// Parse envelope headers.
|
// Parse envelope headers.
|
||||||
header, err := enmime.DecodeHeaders(source)
|
header, err := enmime.DecodeHeaders(source, s.ExtraDecodeHeaders...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("Failed to parse message headers")
|
logger.Error().Err(err).Msg("Failed to parse message headers")
|
||||||
return err
|
return err
|
||||||
@@ -92,6 +96,7 @@ func (s *StoreManager) Deliver(
|
|||||||
To: toAddrs,
|
To: toAddrs,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Size: int64(len(source)),
|
Size: int64(len(source)),
|
||||||
|
Headers: textproto.MIMEHeader(header),
|
||||||
}
|
}
|
||||||
|
|
||||||
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
|
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -106,6 +107,64 @@ func TestDeliverRespectsRecipientPolicy(t *testing.T) {
|
|||||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delivers a message with an X-Spam-Status header, capturing the
|
||||||
|
// InboundMessage event seen by a listener.
|
||||||
|
func deliverWithSpamHeader(t *testing.T, sm *message.StoreManager, extHost *extension.Host) event.InboundMessage {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got event.InboundMessage
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
got = msg
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\nX-Spam-Status: Yes\nX-Not-Registered: no\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverDecodesExtraHeaders(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
sm.ExtraDecodeHeaders = []string{"x-spam-status"} // Not canonical on purpose.
|
||||||
|
|
||||||
|
got := deliverWithSpamHeader(t, sm, extHost)
|
||||||
|
|
||||||
|
// Registered extra header is decoded, with a canonical key.
|
||||||
|
assert.Equal(t, "Yes", got.Headers.Get("X-Spam-Status"))
|
||||||
|
|
||||||
|
// Default set is still decoded.
|
||||||
|
assert.Equal(t, "tsub", got.Headers.Get("Subject"))
|
||||||
|
assert.Equal(t, "from@example.com", got.Headers.Get("From"))
|
||||||
|
|
||||||
|
// Unregistered headers are not decoded.
|
||||||
|
_, ok := got.Headers[textproto.CanonicalMIMEHeaderKey("X-Not-Registered")]
|
||||||
|
assert.False(t, ok, "unregistered header should not be decoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverDefaultHeaderSetOnly(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
got := deliverWithSpamHeader(t, sm, extHost)
|
||||||
|
|
||||||
|
// Without registration, the extra header stays undecoded.
|
||||||
|
_, ok := got.Headers[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")]
|
||||||
|
assert.False(t, ok, "unregistered header should not be decoded")
|
||||||
|
|
||||||
|
// Defaults are present.
|
||||||
|
assert.Equal(t, "tsub", got.Headers.Get("Subject"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverEmitsBeforeMessageStoredEventToHeader(t *testing.T) {
|
func TestDeliverEmitsBeforeMessageStoredEventToHeader(t *testing.T) {
|
||||||
sm, extHost := testStoreManager()
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ func FullAssembly(conf *config.Root) (*Services, error) {
|
|||||||
addrPolicy := &policy.Addressing{Config: conf}
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
// Configure shared components.
|
// Configure shared components.
|
||||||
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
|
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
|
||||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
|
mmanager := &message.StoreManager{
|
||||||
|
AddrPolicy: addrPolicy,
|
||||||
|
Store: store,
|
||||||
|
ExtHost: extHost,
|
||||||
|
}
|
||||||
|
if luaHost != nil {
|
||||||
|
// Lua scripts may register extra headers to decode.
|
||||||
|
mmanager.ExtraDecodeHeaders = luaHost.ScriptConfig().DecodeHeaders
|
||||||
|
}
|
||||||
|
|
||||||
// Start Retention scanner.
|
// Start Retention scanner.
|
||||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)
|
||||||
|
|||||||
Reference in New Issue
Block a user