1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 13:42:30 +00:00

Compare commits

..

2 Commits

Author SHA1 Message Date
James Hillyerd bd4cc4b842 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>
2026-10-23 15:38:37 -07:00
James Hillyerd 59ecd449b6 fix(docker): parameterize frontend platform build arg (#633)
FROM --platform with a constant value triggers the
FromPlatformFlagConstDisallowed lint rule. Move it to an ARG with a
linux/amd64 default, keeping the no-arm-elm workaround while allowing
overrides.
2026-10-10 15:14:32 -07:00
16 changed files with 500 additions and 6 deletions
+5
View File
@@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## [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
+2 -1
View File
@@ -2,7 +2,8 @@
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 AS frontend
ARG FRONTEND_PLATFORM=linux/amd64
FROM --platform=${FRONTEND_PLATFORM} node:20 AS frontend
RUN npm install -g node-gyp
WORKDIR /build
COPY . .
+11
View File
@@ -2,6 +2,7 @@ package event
import (
"net/mail"
"net/textproto"
"time"
)
@@ -27,6 +28,16 @@ type InboundMessage struct {
To []*mail.Address
Subject string
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.
+95
View File
@@ -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
}
+81
View File
@@ -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`)
}
+75
View File
@@ -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
}
+67
View File
@@ -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))
case "size":
ls.Push(lua.LNumber(m.Size))
case "header":
ls.Push(wrapHeader(ls, m.Headers))
default:
// Unknown field.
ls.Push(lua.LNil)
@@ -127,6 +129,8 @@ func inboundMessageNewIndex(ls *lua.LState) int {
m.Subject = ls.CheckString(3)
case "size":
ls.RaiseError("size is read-only")
case "header":
ls.RaiseError("header is read-only")
default:
ls.RaiseError("invalid index %q", index)
}
@@ -2,6 +2,7 @@ package luahost
import (
"net/mail"
"net/textproto"
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
@@ -20,6 +21,10 @@ func TestInboundMessageGetters(t *testing.T) {
},
Subject: "subj1",
Size: 42,
Headers: textproto.MIMEHeader{
"Subject": {"subj1"},
"X-Spam-Status": {"Yes"},
},
}
script := `
assert(msg, "msg should not be nil")
@@ -28,6 +33,10 @@ func TestInboundMessageGetters(t *testing.T) {
assert_eq(msg.subject, "subj1")
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.address, "addr1", "from.address")
@@ -41,10 +50,26 @@ func TestInboundMessageGetters(t *testing.T) {
ls, _ := test.NewLuaState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
registerHeaderType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
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) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
+6
View File
@@ -15,6 +15,7 @@ const (
// Inbucket is the primary Lua interface data structure.
type Inbucket struct {
Config InbucketConfig
After InbucketAfterFuncs
Before InbucketBeforeFuncs
}
@@ -43,6 +44,9 @@ func registerInbucketTypes(ls *lua.LState) {
ud := wrapInbucket(ls, &Inbucket{})
ls.SetGlobal(inbucketName, ud)
// inbucket.config type.
registerInbucketConfigType(ls)
// inbucket.after type.
mt = ls.NewTypeMetatable(inbucketAfterName)
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.
switch field {
case "config":
ls.Push(wrapInbucketConfig(ls, &ib.Config))
case "after":
ls.Push(wrapInbucketAfter(ls, &ib.After))
case "before":
+27 -3
View File
@@ -21,9 +21,10 @@ var ErrNoScript error = errors.New("no script file present")
// Host of Lua extensions.
type Host struct {
extHost *extension.Host
pool *statePool
logContext zerolog.Context
extHost *extension.Host
pool *statePool
logContext zerolog.Context
scriptConfig InbucketConfig
}
// 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()}
if ls, err := pool.getState(); err == nil {
h.wireFunctions(startLogger, ls)
h.readScriptConfig(startLogger, ls)
// State creation works, put it back.
pool.putState(ls)
@@ -90,6 +92,28 @@ func (h *Host) CreateChannel(name string) chan lua.LValue {
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.
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
ib, err := getInbucket(ls)
+27
View File
@@ -41,6 +41,33 @@ func TestLogger(t *testing.T) {
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) {
// Register lua event listener, setup notify channel.
script := `
+1
View File
@@ -48,6 +48,7 @@ func (lp *statePool) newState() (*lua.LState, error) {
registerInbucketTypes(ls)
registerMailAddressType(ls)
registerMessageMetadataType(ls)
registerHeaderType(ls)
registerSMTPResponseType(ls)
registerSMTPSessionType(ls)
+6 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/mail"
"net/textproto"
"strings"
"time"
@@ -41,6 +42,9 @@ type StoreManager struct {
AddrPolicy *policy.Addressing
Store storage.Store
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.
@@ -53,7 +57,7 @@ func (s *StoreManager) Deliver(
logger := log.With().Str("module", "message").Logger()
// Parse envelope headers.
header, err := enmime.DecodeHeaders(source)
header, err := enmime.DecodeHeaders(source, s.ExtraDecodeHeaders...)
if err != nil {
logger.Error().Err(err).Msg("Failed to parse message headers")
return err
@@ -92,6 +96,7 @@ func (s *StoreManager) Deliver(
To: toAddrs,
Subject: subject,
Size: int64(len(source)),
Headers: textproto.MIMEHeader(header),
}
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
+59
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/mail"
"net/textproto"
"strings"
"testing"
"time"
@@ -106,6 +107,64 @@ func TestDeliverRespectsRecipientPolicy(t *testing.T) {
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) {
sm, extHost := testStoreManager()
+9 -1
View File
@@ -50,7 +50,15 @@ func FullAssembly(conf *config.Root) (*Services, error) {
addrPolicy := &policy.Addressing{Config: conf}
// Configure shared components.
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.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)