diff --git a/CHANGELOG.md b/CHANGELOG.md index e08ed4d..a019133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pkg/extension/event/events.go b/pkg/extension/event/events.go index 52a07aa..24136ce 100644 --- a/pkg/extension/event/events.go +++ b/pkg/extension/event/events.go @@ -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. diff --git a/pkg/extension/luahost/bind_config.go b/pkg/extension/luahost/bind_config.go new file mode 100644 index 0000000..c0285ff --- /dev/null +++ b/pkg/extension/luahost/bind_config.go @@ -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 +} diff --git a/pkg/extension/luahost/bind_config_test.go b/pkg/extension/luahost/bind_config_test.go new file mode 100644 index 0000000..b2bd74f --- /dev/null +++ b/pkg/extension/luahost/bind_config_test.go @@ -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`) +} diff --git a/pkg/extension/luahost/bind_header.go b/pkg/extension/luahost/bind_header.go new file mode 100644 index 0000000..f8efc56 --- /dev/null +++ b/pkg/extension/luahost/bind_header.go @@ -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 +} diff --git a/pkg/extension/luahost/bind_header_test.go b/pkg/extension/luahost/bind_header_test.go new file mode 100644 index 0000000..44edcd5 --- /dev/null +++ b/pkg/extension/luahost/bind_header_test.go @@ -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"]) +} diff --git a/pkg/extension/luahost/bind_inboundmessage.go b/pkg/extension/luahost/bind_inboundmessage.go index f440651..8fc0819 100644 --- a/pkg/extension/luahost/bind_inboundmessage.go +++ b/pkg/extension/luahost/bind_inboundmessage.go @@ -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) } diff --git a/pkg/extension/luahost/bind_inboundmessage_test.go b/pkg/extension/luahost/bind_inboundmessage_test.go index e63f8df..942da32 100644 --- a/pkg/extension/luahost/bind_inboundmessage_test.go +++ b/pkg/extension/luahost/bind_inboundmessage_test.go @@ -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"}, diff --git a/pkg/extension/luahost/bind_inbucket.go b/pkg/extension/luahost/bind_inbucket.go index 64fa18e..e99173b 100644 --- a/pkg/extension/luahost/bind_inbucket.go +++ b/pkg/extension/luahost/bind_inbucket.go @@ -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": diff --git a/pkg/extension/luahost/lua.go b/pkg/extension/luahost/lua.go index bfe0232..8201465 100644 --- a/pkg/extension/luahost/lua.go +++ b/pkg/extension/luahost/lua.go @@ -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) diff --git a/pkg/extension/luahost/lua_test.go b/pkg/extension/luahost/lua_test.go index f16d452..437f6d3 100644 --- a/pkg/extension/luahost/lua_test.go +++ b/pkg/extension/luahost/lua_test.go @@ -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 := ` diff --git a/pkg/extension/luahost/pool.go b/pkg/extension/luahost/pool.go index ff700ab..2f2d926 100644 --- a/pkg/extension/luahost/pool.go +++ b/pkg/extension/luahost/pool.go @@ -48,6 +48,7 @@ func (lp *statePool) newState() (*lua.LState, error) { registerInbucketTypes(ls) registerMailAddressType(ls) registerMessageMetadataType(ls) + registerHeaderType(ls) registerSMTPResponseType(ls) registerSMTPSessionType(ls) diff --git a/pkg/message/manager.go b/pkg/message/manager.go index 8f8a3f3..5465fc4 100644 --- a/pkg/message/manager.go +++ b/pkg/message/manager.go @@ -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) diff --git a/pkg/message/manager_test.go b/pkg/message/manager_test.go index 99d28b2..cc6fa1d 100644 --- a/pkg/message/manager_test.go +++ b/pkg/message/manager_test.go @@ -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() diff --git a/pkg/server/lifecycle.go b/pkg/server/lifecycle.go index a770e99..3990964 100644 --- a/pkg/server/lifecycle.go +++ b/pkg/server/lifecycle.go @@ -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)