mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-10-29 21:52:31 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd4cc4b842 | |||
| 59ecd449b6 |
@@ -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
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
### Build frontend
|
### Build frontend
|
||||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
# 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
|
RUN npm install -g node-gyp
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -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