Skip to content

Commit

Permalink
SMTP: Adding multi-header search
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas Hinderberger committed Jul 9, 2024
1 parent 51fd6d0 commit 6d828e8
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 37 deletions.
46 changes: 23 additions & 23 deletions internal/smtp/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,15 @@ func (h *smtpHTTPHandler) handleGUIMessage(w http.ResponseWriter, r *http.Reques
}

func (h *smtpHTTPHandler) handleMessageIndex(w http.ResponseWriter, r *http.Request) {
headerSearchRgx, err := extractSearchRegex(w, r.URL.Query(), "header")
headerSearchRxs, err := extractSearchRegexes(w, r.URL.Query(), "header")
if err != nil {
handlerutil.RespondWithErr(w, http.StatusBadRequest, err)
return
}

receivedMessages := h.server.ReceivedMessages()
if headerSearchRgx != nil {
receivedMessages = SearchByHeader(receivedMessages, headerSearchRgx)
if len(headerSearchRxs) > 0 {
receivedMessages = SearchByHeader(receivedMessages, headerSearchRxs...)
}

messagesOut := make([]any, 0)
Expand All @@ -276,15 +276,15 @@ func (h *smtpHTTPHandler) handleMessageRaw(w http.ResponseWriter, r *http.Reques
}

func (h *smtpHTTPHandler) handleMultipartIndex(w http.ResponseWriter, r *http.Request, c *ReceivedContent) {
headerSearchRgx, err := extractSearchRegex(w, r.URL.Query(), "header")
headerSearchRxs, err := extractSearchRegexes(w, r.URL.Query(), "header")
if err != nil {
handlerutil.RespondWithErr(w, http.StatusBadRequest, err)
return
}

multiparts := c.Multiparts()
if headerSearchRgx != nil {
multiparts = SearchByHeader(multiparts, headerSearchRgx)
if len(headerSearchRxs) > 0 {
multiparts = SearchByHeader(multiparts, headerSearchRxs...)
}

handlerutil.RespondWithJSON(w, http.StatusOK, buildMultipartIndex(multiparts))
Expand Down Expand Up @@ -475,28 +475,28 @@ func buildMultipartMeta(part *ReceivedPart) map[string]any {
return out
}

// extractSearchRegex tries to extract a regular expression from the referenced
// query parameter. If no query parameter is given and otherwise no error has
// occurred, this function returns (nil, nil).
func extractSearchRegex(
// extractSearchRegexes tries to extract the regular expression(s) from the
// referenced query parameter. If no query parameter is given and otherwise
// no error has occurred, this function returns no error.
func extractSearchRegexes(
w http.ResponseWriter, queryParams map[string][]string, paramName string,
) (*regexp.Regexp, error) {
searchParam, ok := queryParams[paramName]
) ([]*regexp.Regexp, error) {
searchParams, ok := queryParams[paramName]
if ok {
if len(searchParam) != 1 {
return nil, fmt.Errorf(
"Encountered multiple %q params", paramName,
)
}
out := make([]*regexp.Regexp, len(searchParams))

for i, p := range searchParams {
re, err := regexp.Compile(p)
if err != nil {
return nil, fmt.Errorf(
"could not compile %q regex %q: %w", paramName, p, err,
)
}

re, err := regexp.Compile(searchParam[0])
if err != nil {
return nil, fmt.Errorf(
"could not compile %q regex: %w", paramName, err,
)
out[i] = re
}

return re, nil
return out, nil
}

return nil, nil
Expand Down
30 changes: 21 additions & 9 deletions internal/smtp/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,38 @@ import (
"regexp"
)

// SearchByHeader returns the list of all given ContentHavers that
// have at least one header matching the given regular expression.
// SearchByHeader returns the list of all given ContentHavers that,
// for each of the given regular expressions, has at least one header
// matching it (different regexes can be matched by different headers or
// the same header).
//
// Note that the regex is performed for each header value individually,
// including for multi-value headers. The header value is first serialized
// by concatenating it after the header name, colon and space. It is not
// being encoded as if for transport (e.g. quoted-printable),
// but concatenated as-is.
func SearchByHeader[T ContentHaver](haystack []T, re *regexp.Regexp) []T {
// Note that in the context of this function, a regex is performed for each
// header value individually, including for multi-value headers. The header
// value is first serialized by concatenating it after the header name, colon
// and space. It is not being encoded as if for transport (e.g. quoted-
// printable), but concatenated as-is.
func SearchByHeader[T ContentHaver](haystack []T, rxs ...*regexp.Regexp) []T {
out := make([]T, 0, len(haystack))

for _, c := range haystack {
if anyHeaderMatches(c.Content().Headers(), re) {
if allRegexesMatchAnyHeader(c.Content().Headers(), rxs) {
out = append(out, c)
}
}

return out
}

func allRegexesMatchAnyHeader(headers map[string][]string, rxs []*regexp.Regexp) bool {
for _, re := range rxs {
if !anyHeaderMatches(headers, re) {
return false
}
}

return true
}

func anyHeaderMatches(headers map[string][]string, re *regexp.Regexp) bool {
for k, vs := range headers {
for _, v := range vs {
Expand Down
160 changes: 156 additions & 4 deletions internal/smtp/smtp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
_ "embed"
"net"
"regexp"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -103,6 +104,54 @@ func TestMessageSearch(t *testing.T) {
}
}

func TestMessageSearchAND(t *testing.T) {
testCases := []struct {
queries []string
expectedIndices []int
}{
{
queries: []string{
"Subject: Example Message",
"To: testreceiver6\\@.*",
},
expectedIndices: []int{5},
},
{
queries: []string{
"From: testsender5\\@.*",
"Content-Transfer-Encoding: .*",
},
expectedIndices: []int{4},
},
{
queries: []string{
"Subject: Example Message",
"Content-Type: text/plain",
},
expectedIndices: []int{2, 4},
},
}

for i := range testCases {
testCase := testCases[i]

t.Run(strconv.Itoa(i), func(t *testing.T) {
rxs := make([]*regexp.Regexp, len(testCase.queries))
for j, query := range testCase.queries {
rxs[j] = regexp.MustCompile(query)
}

actual := SearchByHeader(server.ReceivedMessages(), rxs...)

actualIndices := make([]int, len(actual))
for ai, av := range actual {
actualIndices[ai] = av.index
}
assert.ElementsMatch(t, testCase.expectedIndices, actualIndices)
})
}
}

func TestMultipartSearch(t *testing.T) {
// This test uses message #8 for all of its tests.

Expand All @@ -119,11 +168,16 @@ func TestMultipartSearch(t *testing.T) {
},
{
queries: []string{
"X-Funky-Header",
"Content-Transfer-Encoding",
},
expectedIndices: []int{0, 1},
},
{
queries: []string{
"X-Funky-Header",
},
expectedIndices: []int{0, 1, 2, 3},
},
{
queries: []string{
"X-Funky-Header: Käse",
Expand Down Expand Up @@ -170,6 +224,52 @@ func TestMultipartSearch(t *testing.T) {
}
}

func TestMultipartSearchAND(t *testing.T) {
// This test uses message #8 for all of its tests.

testCases := []struct {
queries []string
expectedIndices []int
}{
{
queries: []string{
"X-Funky-Header: .*se$",
"Content-Type: text/plain",
},
expectedIndices: []int{1, 3},
},
{
queries: []string{
"X-Funky-Header: ..se$",
"Content-Type: text/plain",
},
expectedIndices: []int{1},
},
}

for i := range testCases {
testCase := testCases[i]

t.Run(strconv.Itoa(i), func(t *testing.T) {
rxs := make([]*regexp.Regexp, len(testCase.queries))
for j, query := range testCase.queries {
rxs[j] = regexp.MustCompile(query)
}

msg, err := server.ReceivedMessage(8)
require.NoError(t, err)

actual := SearchByHeader(msg.Content().Multiparts(), rxs...)

actualIndices := make([]int, len(actual))
for ai, av := range actual {
actualIndices[ai] = av.index
}
assert.ElementsMatch(t, testCase.expectedIndices, actualIndices)
})
}
}

func assertHeadersEqual(t *testing.T, expected, actual map[string][]string) {
assert.Equal(t, len(expected), len(actual))

Expand Down Expand Up @@ -351,6 +451,7 @@ Trailing text is ignored.`),
smtpRcptTo: []string{"testreceiver3@programmfabrik.de"},
rawMessageData: []byte(`From: testsender3@programmfabrik.de
To: testreceiver3@programmfabrik.de
Subject: Example Message
Content-Type: text/plain; charset=utf-8
Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`),
Expand All @@ -359,6 +460,7 @@ Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`),
headers: map[string][]string{
"From": {"testsender3@programmfabrik.de"},
"To": {"testreceiver3@programmfabrik.de"},
"Subject": {"Example Message"},
"Content-Type": {"text/plain; charset=utf-8"},
},
body: []byte(`Noch eine Testmail. Diesmal mit nicht-ASCII-Zeichen: äöüß`),
Expand Down Expand Up @@ -401,6 +503,7 @@ w6TDtsO8w58K`),
smtpRcptTo: []string{"testreceiver5@programmfabrik.de"},
rawMessageData: []byte(`From: testsender5@programmfabrik.de
To: testreceiver5@programmfabrik.de
Subject: Example Message
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Expand All @@ -411,6 +514,7 @@ d-printable.`),
headers: map[string][]string{
"From": {"testsender5@programmfabrik.de"},
"To": {"testreceiver5@programmfabrik.de"},
"Subject": {"Example Message"},
"Content-Type": {"text/plain; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
},
Expand Down Expand Up @@ -573,6 +677,16 @@ X-Funky-Header: Käse
Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
d-printable.
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/html; charset=utf-8
X-Funky-Header: Nase
<i>Foo</i>
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/plain; charset=utf-8
X-Funky-Header: Phase
Foobar.
--d36c3118be4745f9a1cb4556d11fe92d--`),
receivedAt: testTime,
content: &ReceivedContent{
Expand All @@ -598,6 +712,16 @@ X-Funky-Header: Käse
Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
d-printable.
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/html; charset=utf-8
X-Funky-Header: Nase
<i>Foo</i>
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/plain; charset=utf-8
X-Funky-Header: Phase
Foobar.
--d36c3118be4745f9a1cb4556d11fe92d--`),
contentType: "multipart/mixed",
contentTypeParams: map[string]string{
Expand Down Expand Up @@ -636,6 +760,34 @@ d-printable.
},
},
},
{
index: 2,
content: &ReceivedContent{
headers: map[string][]string{
"Content-Type": {"text/html; charset=utf-8"},
"X-Funky-Header": {"Nase"},
},
body: []byte(`<i>Foo</i>`),
contentType: "text/html",
contentTypeParams: map[string]string{
"charset": "utf-8",
},
},
},
{
index: 3,
content: &ReceivedContent{
headers: map[string][]string{
"Content-Type": {"text/plain; charset=utf-8"},
"X-Funky-Header": {"Phase"},
},
body: []byte(`Foobar.`),
contentType: "text/plain",
contentTypeParams: map[string]string{
"charset": "utf-8",
},
},
},
},
},
},
Expand All @@ -653,7 +805,7 @@ Content-type: multipart/alternative; boundary="d36c3118be4745f9a1cb4556d11fe92d"
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/plain; charset=utf-8
Some plain text for clients that don't support multipart.
Some plain text for clients that don't support nested multipart.
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
Expand All @@ -680,7 +832,7 @@ This is the <i>second</i> subpart.
body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: text/plain; charset=utf-8
Some plain text for clients that don't support multipart.
Some plain text for clients that don't support nested multipart.
--d36c3118be4745f9a1cb4556d11fe92d
Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
Expand All @@ -706,7 +858,7 @@ This is the <i>second</i> subpart.
headers: map[string][]string{
"Content-Type": {"text/plain; charset=utf-8"},
},
body: []byte(`Some plain text for clients that don't support multipart.`),
body: []byte(`Some plain text for clients that don't support nested multipart.`),

contentType: "text/plain",
contentTypeParams: map[string]string{
Expand Down
Loading

0 comments on commit 6d828e8

Please sign in to comment.