diff --git a/README.md b/README.md
index 9ceed33c..6bb55271 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,11 @@ This starts the command with the following default settings:
| --- | --- |
| `stop-on-fail` | Stop execution of later test suites if a test suite fails |
+### Keep running
+
+- `keep-running`: Wait for a keyboard interrupt after each test suite invocation.
+ This can be useful for keeping the HTTP / SMTP server for manual inspection.
+
### Configure logging
Per default request and response of a request will be logged on test failure. If you want to see more information you
@@ -181,6 +186,12 @@ Manifest is loaded as **template**, so you can use variables, Go **range** and *
"testmode": false
},
+ // Optional temporary SMTP Server (see below)
+ "smtp_server": {
+ "addr": ":9025",
+ "max_message_size": 1000000,
+ },
+
// Specify a unique log behavior only for this single test.
"log_network": true,
"log_verbose": false,
@@ -2855,3 +2866,275 @@ The expected response:
}
}
```
+
+## SMTP Server
+### Summary and Configuration
+The apitest tool can run a mock SMTP server intended to catch locally sent
+emails for testing purposes.
+
+To add the SMTP Server to your test, put the following in your manifest:
+
+```yaml
+{
+ "smtp_server": {
+ "addr": ":9025", // address to listen on
+ "max_message_size": 1000000 // maximum accepted message size in bytes
+ // (defaults to 30MiB)
+ }
+}
+```
+
+The server will then listen on the specified address for incoming emails.
+Incoming messages are stored in memory and can be accessed using the HTTP
+endpoints described further below. No authentication is performed when
+receiving messages.
+
+If the test mode is enabled on the HTTP server and an SMTP server is also
+configured, both the HTTP and the SMTP server will be available during
+interactive testing.
+
+### HTTP Endpoints
+On its own, the SMTP server has only limited use, e.g. as an email sink for
+applications that require such an email sink to function. But when combined
+with the HTTP server (see above in section [HTTP Server](#http-server)),
+the messages received by the SMTP server can be reproduced in JSON format.
+
+When both the SMTP server and the HTTP server are enabled, the following
+additional endpoints are made available on the HTTP server:
+
+#### /smtp/gui
+A very basic HTML/JavaScript GUI that displays and auto-refreshes the received
+messages is made available on the `/smtp/gui` endpoint.
+
+#### /smtp
+On the `/smtp` endpoint, an index of all received messages will be made
+available as JSON in the following schema:
+
+```json
+{
+ "count": 3,
+ "messages": [
+ {
+ "from": [
+ "testsender@programmfabrik.de"
+ ],
+ "idx": 0,
+ "isMultipart": false,
+ "receivedAt": "2024-07-02T11:23:31.212023129+02:00",
+ "smtpFrom": "testsender@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver@programmfabrik.de"
+ ],
+ "to": [
+ "testreceiver@programmfabrik.de"
+ ]
+ },
+ {
+ "from": [
+ "testsender2@programmfabrik.de"
+ ],
+ "idx": 1,
+ "isMultipart": true,
+ "receivedAt": "2024-07-02T11:23:31.212523916+02:00",
+ "smtpFrom": "testsender2@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver2@programmfabrik.de"
+ ],
+ "subject": "Example Message",
+ "to": [
+ "testreceiver2@programmfabrik.de"
+ ]
+ },
+ {
+ "from": [
+ "testsender3@programmfabrik.de"
+ ],
+ "idx": 2,
+ "isMultipart": false,
+ "receivedAt": "2024-07-02T11:23:31.212773829+02:00",
+ "smtpFrom": "testsender3@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver3@programmfabrik.de"
+ ],
+ "to": [
+ "testreceiver3@programmfabrik.de"
+ ]
+ }
+ ]
+}
+```
+
+Headers that were encoded according to RFC2047 are decoded first.
+
+#### /smtp/$idx
+On the `/smtp/$idx` endpoint (e.g. `/smtp/1`), metadata about the message with
+the corresponding index is made available as JSON:
+
+```json
+{
+ "bodySize": 306,
+ "contentType": "multipart/mixed",
+ "contentTypeParams": {
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d"
+ },
+ "from": [
+ "testsender2@programmfabrik.de"
+ ],
+ "headers": {
+ "Content-Type": [
+ "multipart/mixed; boundary=\"d36c3118be4745f9a1cb4556d11fe92d\""
+ ],
+ "Date": [
+ "Tue, 25 Jun 2024 11:15:57 +0200"
+ ],
+ "From": [
+ "testsender2@programmfabrik.de"
+ ],
+ "Mime-Version": [
+ "1.0"
+ ],
+ "Subject": [
+ "Example Message"
+ ],
+ "To": [
+ "testreceiver2@programmfabrik.de"
+ ]
+ },
+ "idx": 1,
+ "isMultipart": true,
+ "multiparts": [
+ {
+ "bodySize": 15,
+ "contentType": "text/plain",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain; charset=utf-8"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": false
+ },
+ {
+ "bodySize": 39,
+ "contentType": "text/html",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/html; charset=utf-8"
+ ]
+ },
+ "idx": 1,
+ "isMultipart": false
+ }
+ ],
+ "multipartsCount": 2,
+ "receivedAt": "2024-07-02T12:54:44.443488367+02:00",
+ "smtpFrom": "testsender2@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver2@programmfabrik.de"
+ ],
+ "subject": "Example Message",
+ "to": [
+ "testreceiver2@programmfabrik.de"
+ ]
+}
+```
+
+Headers that were encoded according to RFC2047 are decoded first.
+
+#### /smtp/$idx/body
+On the `/smtp/$idx/body` endpoint (e.g. `/smtp/1/body`), the message body
+(excluding message headers, including multipart part headers) is made availabe
+for the message with the corresponding index.
+
+If the message was sent with a `Content-Transfer-Encoding` of either `base64`
+or `quoted-printable`, the endpoint returns the decoded body.
+
+If the message was sent with a `Content-Type` header, it will be passed through
+to the HTTP response.
+
+#### /smtp/$idx/multipart
+For multipart messages, the `/smtp/$idx/multipart` endpoint (e.g.
+`/smtp/1/multipart`) will contain an index of that messages multiparts in the
+following schema:
+
+```json
+{
+ "multiparts": [
+ {
+ "bodySize": 15,
+ "contentType": "text/plain",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain; charset=utf-8"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": false
+ },
+ {
+ "bodySize": 39,
+ "contentType": "text/html",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/html; charset=utf-8"
+ ]
+ },
+ "idx": 1,
+ "isMultipart": false
+ }
+ ],
+ "multipartsCount": 2
+}
+```
+
+#### /smtp/$idx[/multipart/$partIdx]+
+On the `/smtp/$idx/multipart/$partIdx` endpoint (e.g. `/smtp/1/multipart/0`),
+metadata about the multipart with the corresponding index is made available:
+
+```json
+{
+ "bodySize": 15,
+ "contentType": "text/plain",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain; charset=utf-8"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": false
+}
+```
+
+Headers that were encoded according to RFC2047 are decoded first.
+
+The endpoint can be called recursively for nested multipart messages, e.g.
+`/smtp/1/multipart/0/multipart/1`.
+
+#### /smtp/$idx[/multipart/$partIdx]+/body
+On the `/smtp/$idx/multipart/$partIdx/body` endpoint (e.g.
+`/smtp/1/multipart/0/body`), the body of the multipart (excluding headers)
+is made available.
+
+If the multipart was sent with a `Content-Transfer-Encoding` of either `base64`
+or `quoted-printable`, the endpoint returns the decoded body.
+
+If the message was sent with a `Content-Type` header, it will be passed through
+to the HTTP response.
+
+The endpoint can be called recursively for nested multipart messages, e.g.
+`/smtp/1/multipart/0/multipart/1/body`.
diff --git a/api_testsuite.go b/api_testsuite.go
index 5be3eb70..003758a9 100644
--- a/api_testsuite.go
+++ b/api_testsuite.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
+ "os/signal"
"path/filepath"
"strconv"
"sync"
@@ -17,6 +18,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/programmfabrik/apitest/internal/httpproxy"
+ "github.com/programmfabrik/apitest/internal/smtp"
"github.com/programmfabrik/apitest/pkg/lib/datastore"
"github.com/programmfabrik/apitest/pkg/lib/filesystem"
"github.com/programmfabrik/apitest/pkg/lib/report"
@@ -34,6 +36,10 @@ type Suite struct {
Testmode bool `json:"testmode"`
Proxy httpproxy.ProxyConfig `json:"proxy"`
} `json:"http_server,omitempty"`
+ SmtpServer *struct {
+ Addr string `json:"addr"`
+ MaxMessageSize int64 `json:"max_message_size"`
+ } `json:"smtp_server,omitempty"`
Tests []any `json:"tests"`
Store map[string]any `json:"store"`
@@ -48,12 +54,13 @@ type Suite struct {
reporterRoot *report.ReportElement
index int
serverURL *url.URL
- httpServer http.Server
+ httpServer *http.Server
httpServerProxy *httpproxy.Proxy
httpServerDir string
idleConnsClosed chan struct{}
HTTPServerHost string
loader template.Loader
+ smtpServer *smtp.Server
}
// NewTestSuite creates a new suite on which we execute our tests on. Normally this only gets call from within the apitest main command
@@ -175,11 +182,15 @@ func (ats *Suite) Run() bool {
logrus.Infof("[%2d] '%s'", ats.index, ats.Name)
}
+ ats.StartSmtpServer()
+ defer ats.StopSmtpServer()
+
ats.StartHttpServer()
+ defer ats.StopHttpServer()
err := os.Chdir(ats.manifestDir)
if err != nil {
- logrus.Errorf("Unable to switch working directory to %q", ats.manifestDir)
+ logrus.Fatalf("Unable to switch working directory to %q", ats.manifestDir)
}
start := time.Now()
@@ -220,7 +231,21 @@ func (ats *Suite) Run() bool {
}
}
- ats.StopHttpServer()
+ if keepRunning { // flag defined in main.go
+ logrus.Info("Waiting until a keyboard interrupt (usually CTRL+C) is received...")
+
+ if ats.HttpServer != nil {
+ logrus.Info("HTTP Server URL:", ats.HttpServer.Addr)
+ }
+ if ats.SmtpServer != nil {
+ logrus.Info("SMTP Server URL:", ats.SmtpServer.Addr)
+ }
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt)
+
+ <-sigChan
+ }
return success
}
diff --git a/go.mod b/go.mod
index 9093b2cc..ca10b0cd 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/PuerkitoBio/goquery v1.8.1
github.com/clbanning/mxj v1.8.4
+ github.com/emersion/go-smtp v0.21.2
github.com/mattn/go-sqlite3 v1.14.17
github.com/moul/http2curl v1.0.0
github.com/pkg/errors v0.9.1
@@ -32,6 +33,7 @@ require (
github.com/antchfx/xmlquery v1.3.18 // indirect
github.com/antchfx/xpath v1.2.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
diff --git a/go.sum b/go.sum
index 49699b1d..e631efdc 100644
--- a/go.sum
+++ b/go.sum
@@ -69,6 +69,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA=
+github.com/emersion/go-smtp v0.21.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
diff --git a/http_server.go b/http_server.go
index e80d95f0..f4a44112 100644
--- a/http_server.go
+++ b/http_server.go
@@ -11,7 +11,9 @@ import (
"path/filepath"
"unicode/utf8"
+ "github.com/pkg/errors"
"github.com/programmfabrik/apitest/internal/httpproxy"
+ "github.com/programmfabrik/apitest/pkg/lib/util"
"github.com/programmfabrik/golib"
"github.com/sirupsen/logrus"
)
@@ -19,10 +21,11 @@ import (
// StartHttpServer start a simple http server that can server local test resources during the testsuite is running
func (ats *Suite) StartHttpServer() {
- if ats.HttpServer == nil {
+ if ats.HttpServer == nil || ats.httpServer != nil {
return
}
+ // TODO: Can we remove idleConnsClosed, because it does not seem to do anything?
ats.idleConnsClosed = make(chan struct{})
mux := http.NewServeMux()
@@ -48,7 +51,12 @@ func (ats *Suite) StartHttpServer() {
ats.httpServerProxy = httpproxy.New(ats.HttpServer.Proxy)
ats.httpServerProxy.RegisterRoutes(mux, "/", ats.Config.LogShort)
- ats.httpServer = http.Server{
+ // Register SMTP server query routes
+ if ats.smtpServer != nil {
+ ats.smtpServer.RegisterRoutes(mux, "/", ats.Config.LogShort)
+ }
+
+ ats.httpServer = &http.Server{
Addr: ats.HttpServer.Addr,
Handler: mux,
}
@@ -59,10 +67,9 @@ func (ats *Suite) StartHttpServer() {
}
err := ats.httpServer.ListenAndServe()
- if err != http.ErrServerClosed {
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
// Error starting or closing listener:
- logrus.Errorf("HTTP server ListenAndServe: %v", err)
- return
+ logrus.Fatal("HTTP server ListenAndServe:", err)
}
}
@@ -72,6 +79,8 @@ func (ats *Suite) StartHttpServer() {
run()
} else {
go run()
+
+ util.WaitForTCP(ats.HttpServer.Addr)
}
}
@@ -94,7 +103,7 @@ func customStaticHandler(h http.Handler) http.HandlerFunc {
// StopHttpServer stop the http server that was started for this test suite
func (ats *Suite) StopHttpServer() {
- if ats.HttpServer == nil {
+ if ats.HttpServer == nil || ats.httpServer == nil {
return
}
@@ -107,7 +116,8 @@ func (ats *Suite) StopHttpServer() {
} else if !ats.Config.LogShort {
logrus.Infof("Http Server stopped: %s", ats.httpServerDir)
}
- return
+
+ ats.httpServer = nil
}
type ErrorResponse struct {
diff --git a/internal/handlerutil/util.go b/internal/handlerutil/util.go
new file mode 100644
index 00000000..9f4b6cdc
--- /dev/null
+++ b/internal/handlerutil/util.go
@@ -0,0 +1,40 @@
+package handlerutil
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+)
+
+// errorResponse definition
+type errorResponse struct {
+ Error string `json:"error"`
+}
+
+// LogH is a middleware that logs requests via logrus.Debugf.
+func LogH(skipLogs bool, next http.Handler) http.Handler {
+ if skipLogs {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ logrus.Debugf("http-server: %s: %q", r.Method, r.URL)
+ next.ServeHTTP(w, r)
+ })
+}
+
+// RespondWithErr responds using a JSON-serialized error message.
+func RespondWithErr(w http.ResponseWriter, status int, err error) {
+ RespondWithJSON(w, status, errorResponse{err.Error()})
+}
+
+// RespondWithJSON responds with a JSON-serialized value.
+func RespondWithJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+
+ err := json.NewEncoder(w).Encode(v)
+ if err != nil {
+ logrus.Errorf("Could not encode JSON response: %s (%v)", err, v)
+ }
+}
diff --git a/internal/httpproxy/proxy.go b/internal/httpproxy/proxy.go
index f4212b26..ee6b1d56 100644
--- a/internal/httpproxy/proxy.go
+++ b/internal/httpproxy/proxy.go
@@ -3,7 +3,7 @@ package httpproxy
import (
"net/http"
- "github.com/sirupsen/logrus"
+ "github.com/programmfabrik/apitest/internal/handlerutil"
)
// ProxyConfig definition
@@ -24,17 +24,7 @@ func New(cfg ProxyConfig) *Proxy {
// RegisterRoutes for the proxy store/retrieve
func (proxy *Proxy) RegisterRoutes(mux *http.ServeMux, prefix string, skipLogs bool) {
for _, s := range *proxy {
- mux.Handle(prefix+"proxywrite/"+s.Name, logH(skipLogs, http.HandlerFunc(s.write)))
- mux.Handle(prefix+"proxyread/"+s.Name, logH(skipLogs, http.HandlerFunc(s.read)))
+ mux.Handle(prefix+"proxywrite/"+s.Name, handlerutil.LogH(skipLogs, http.HandlerFunc(s.write)))
+ mux.Handle(prefix+"proxyread/"+s.Name, handlerutil.LogH(skipLogs, http.HandlerFunc(s.read)))
}
}
-
-func logH(skipLogs bool, next http.Handler) http.Handler {
- if skipLogs {
- return next
- }
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- logrus.Debugf("http-server: %s: %q", r.Method, r.URL)
- next.ServeHTTP(w, r)
- })
-}
diff --git a/internal/httpproxy/store.go b/internal/httpproxy/store.go
index a4d589b3..99ead104 100644
--- a/internal/httpproxy/store.go
+++ b/internal/httpproxy/store.go
@@ -9,7 +9,8 @@ import (
"strconv"
"github.com/pkg/errors"
- "github.com/sirupsen/logrus"
+
+ "github.com/programmfabrik/apitest/internal/handlerutil"
)
// Mode definition
@@ -20,11 +21,6 @@ const (
ModePassthrough Mode = "passthru"
)
-// errorResponse definition
-type errorResponse struct {
- Error string `json:"error"`
-}
-
// request definition
type request struct {
Method string `json:"method"`
@@ -69,7 +65,7 @@ func (st *store) write(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
reqData.Body, err = ioutil.ReadAll(r.Body)
if err != nil {
- respondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not read request body: %s", err))
+ handlerutil.RespondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not read request body: %s", err))
return
}
}
@@ -80,7 +76,7 @@ func (st *store) write(w http.ResponseWriter, r *http.Request) {
Offset int `json:"offset"`
}{offset})
if err != nil {
- respondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not encode response: %s", err))
+ handlerutil.RespondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not encode response: %s", err))
}
}
@@ -98,14 +94,14 @@ func (st *store) read(w http.ResponseWriter, r *http.Request) {
if offsetStr != "" {
offset, err = strconv.Atoi(offsetStr)
if err != nil {
- respondWithErr(w, http.StatusBadRequest, errors.Errorf("Invalid offset %s", offsetStr))
+ handlerutil.RespondWithErr(w, http.StatusBadRequest, errors.Errorf("Invalid offset %s", offsetStr))
return
}
}
count := len(st.Data)
if offset >= count {
- respondWithErr(w, http.StatusBadRequest, errors.Errorf("Offset (%d) is higher than count (%d)", offset, count))
+ handlerutil.RespondWithErr(w, http.StatusBadRequest, errors.Errorf("Offset (%d) is higher than count (%d)", offset, count))
return
}
@@ -130,15 +126,6 @@ func (st *store) read(w http.ResponseWriter, r *http.Request) {
_, err = w.Write(req.Body)
if err != nil {
- respondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not encode response: %s", err))
- }
-}
-
-// respondWithErr helper
-func respondWithErr(w http.ResponseWriter, status int, err error) {
- w.WriteHeader(status)
- err2 := json.NewEncoder(w).Encode(errorResponse{err.Error()})
- if err2 != nil {
- logrus.Errorf("Could not encode the error (%s) response itself: %s", err, err2)
+ handlerutil.RespondWithErr(w, http.StatusInternalServerError, errors.Errorf("Could not encode response: %s", err))
}
}
diff --git a/internal/smtp/gui_index.html b/internal/smtp/gui_index.html
new file mode 100644
index 00000000..325e2f09
--- /dev/null
+++ b/internal/smtp/gui_index.html
@@ -0,0 +1,70 @@
+
+
+
+
+apitest mock SMTP server GUI
+
+
+
+
+
+
+
+
+
+
+ Index |
+ Received |
+ From |
+ To |
+ Subject |
+ Details |
+
+
+
+
+
+
+
+
+
diff --git a/internal/smtp/gui_message.html b/internal/smtp/gui_message.html
new file mode 100644
index 00000000..ad44491f
--- /dev/null
+++ b/internal/smtp/gui_message.html
@@ -0,0 +1,129 @@
+
+
+
+
+apitest mock SMTP server GUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Content Preview:
+
+
+
+
+
+
+
+
+
diff --git a/internal/smtp/http.go b/internal/smtp/http.go
new file mode 100644
index 00000000..f0f9ddca
--- /dev/null
+++ b/internal/smtp/http.go
@@ -0,0 +1,516 @@
+package smtp
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "net/mail"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Masterminds/sprig/v3"
+ "github.com/programmfabrik/apitest/internal/handlerutil"
+ "github.com/sirupsen/logrus"
+)
+
+//go:embed gui_index.html
+var guiIndexTemplateSrc string
+var guiIndexTemplate = template.Must(template.New("gui_index").Parse(guiIndexTemplateSrc))
+
+//go:embed gui_message.html
+var guiMessageTemplateSrc string
+var guiMessageTemplate = template.Must(template.
+ New("gui_message").
+ Funcs(sprig.TxtFuncMap()).
+ Parse(guiMessageTemplateSrc),
+)
+
+type smtpHTTPHandler struct {
+ server *Server
+ prefix string
+}
+
+// RegisterRoutes sets up HTTP routes for inspecting the SMTP Server's
+// received messages.
+func (s *Server) RegisterRoutes(mux *http.ServeMux, prefix string, skipLogs bool) {
+ handler := &smtpHTTPHandler{
+ server: s,
+ prefix: path.Join(prefix, "smtp"),
+ }
+
+ mux.Handle(handler.prefix, handlerutil.LogH(skipLogs, handler))
+ mux.Handle(handler.prefix+"/", handlerutil.LogH(skipLogs, handler))
+}
+
+func (h *smtpHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ path := path.Clean(r.URL.Path)
+ path = strings.TrimPrefix(path, h.prefix)
+ path = strings.TrimPrefix(path, "/")
+
+ if path == "" {
+ h.handleMessageIndex(w, r)
+ return
+ }
+
+ pathParts := strings.Split(path, "/")
+ fmt.Println(pathParts)
+
+ // We now know that pathParts must have at least length 1, since empty path
+ // was already handled above.
+
+ switch pathParts[0] {
+ case "gui":
+ h.routeGUIEndpoint(w, r, pathParts)
+ case "postmessage":
+ h.handlePostMessage(w, r)
+ default:
+ h.routeMessageEndpoint(w, r, pathParts)
+ }
+}
+
+func (h *smtpHTTPHandler) routeGUIEndpoint(w http.ResponseWriter, r *http.Request, pathParts []string) {
+ if len(pathParts) == 0 {
+ handlerutil.RespondWithErr(
+ w, http.StatusInternalServerError,
+ fmt.Errorf("routeGUIEndpoint was called with empty pathParts"),
+ )
+ return
+ }
+
+ if len(pathParts) == 1 {
+ h.handleGUIIndex(w, r)
+ return
+ }
+
+ // We know at this point that len(pathParts) must be >= 2
+
+ msg, ok := h.retrieveMessage(w, pathParts[1])
+ if !ok {
+ return
+ }
+
+ if len(pathParts) == 2 {
+ h.handleGUIMessage(w, r, msg)
+ return
+ }
+
+ // If routing failed, return status 404.
+ w.WriteHeader(http.StatusNotFound)
+}
+
+func (h *smtpHTTPHandler) routeMessageEndpoint(w http.ResponseWriter, r *http.Request, pathParts []string) {
+ if len(pathParts) == 0 {
+ handlerutil.RespondWithErr(
+ w, http.StatusInternalServerError,
+ fmt.Errorf("routeMessageEndpoint was called with empty pathParts"),
+ )
+ return
+ }
+
+ msg, ok := h.retrieveMessage(w, pathParts[0])
+ if !ok {
+ return
+ }
+
+ if len(pathParts) == 1 {
+ h.handleMessageMeta(w, r, msg)
+ return
+ }
+ if len(pathParts) == 2 && pathParts[1] == "raw" {
+ h.handleMessageRaw(w, r, msg)
+ return
+ }
+
+ h.subrouteContentEndpoint(w, r, msg.Content(), pathParts[1:])
+}
+
+// subrouteContentEndpoint recursively finds a route for the remaining path parts
+// based on the given ReceivedContent.
+func (h *smtpHTTPHandler) subrouteContentEndpoint(
+ w http.ResponseWriter, r *http.Request, c *ReceivedContent, remainingPathParts []string,
+) {
+ ensureIsMultipart := func() bool {
+ if !c.IsMultipart() {
+ handlerutil.RespondWithErr(w, http.StatusNotFound, fmt.Errorf(
+ "multipart endpoint was requested for non-multipart content",
+ ))
+ return false
+ }
+
+ return true
+ }
+
+ if len(remainingPathParts) == 1 {
+ switch remainingPathParts[0] {
+ case "body":
+ h.handleContentBody(w, r, c)
+ return
+ case "multipart":
+ if !ensureIsMultipart() {
+ return
+ }
+
+ h.handleMultipartIndex(w, r, c)
+ return
+ }
+ }
+
+ if len(remainingPathParts) > 1 && remainingPathParts[0] == "multipart" {
+ if !ensureIsMultipart() {
+ return
+ }
+
+ multiparts := c.Multiparts()
+
+ partIdx, err := strconv.Atoi(remainingPathParts[1])
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusBadRequest,
+ fmt.Errorf("could not parse multipart index: %w", err),
+ )
+ return
+ }
+
+ if partIdx >= len(multiparts) {
+ handlerutil.RespondWithErr(w, http.StatusNotFound, fmt.Errorf(
+ "ReceivedContent does not contain multipart with index %d", partIdx,
+ ))
+ return
+ }
+
+ part := multiparts[partIdx]
+
+ if len(remainingPathParts) == 2 {
+ h.handleMultipartMeta(w, r, part)
+ return
+ }
+
+ h.subrouteContentEndpoint(w, r, part.Content(), remainingPathParts[2:])
+ return
+ }
+
+ // If routing failed, return status 404.
+ w.WriteHeader(http.StatusNotFound)
+}
+
+func (h *smtpHTTPHandler) handleContentBody(w http.ResponseWriter, r *http.Request, c *ReceivedContent) {
+ contentType, ok := c.Headers()["Content-Type"]
+ if ok {
+ w.Header()["Content-Type"] = contentType
+ }
+
+ w.Write(c.Body())
+}
+
+func (h *smtpHTTPHandler) handleGUIIndex(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ err := guiIndexTemplate.Execute(w, map[string]any{"prefix": h.prefix})
+ if err != nil {
+ logrus.Error("error rendering GUI Index:", err)
+ }
+}
+
+func (h *smtpHTTPHandler) handleGUIMessage(w http.ResponseWriter, r *http.Request, msg *ReceivedMessage) {
+ metadata := buildMessageFullMeta(msg)
+ metadataJson, err := json.MarshalIndent(metadata, "", " ")
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusInternalServerError,
+ fmt.Errorf("could not build metadata JSON: %w", err),
+ )
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ err = guiMessageTemplate.Execute(w, map[string]any{
+ "prefix": h.prefix,
+ "metadata": metadata,
+ "metadataJson": string(metadataJson),
+ })
+ if err != nil {
+ logrus.Error("error rendering GUI Message:", err)
+ }
+}
+
+func (h *smtpHTTPHandler) handleMessageIndex(w http.ResponseWriter, r *http.Request) {
+ headerSearchRxs, err := extractSearchRegexes(w, r.URL.Query(), "header")
+ if err != nil {
+ handlerutil.RespondWithErr(w, http.StatusBadRequest, err)
+ return
+ }
+
+ receivedMessages := h.server.ReceivedMessages()
+ if len(headerSearchRxs) > 0 {
+ receivedMessages = SearchByHeader(receivedMessages, headerSearchRxs...)
+ }
+
+ messagesOut := make([]any, 0)
+
+ for _, msg := range receivedMessages {
+ messagesOut = append(messagesOut, buildMessageBasicMeta(msg))
+ }
+
+ out := make(map[string]any)
+ out["count"] = len(receivedMessages)
+ out["messages"] = messagesOut
+
+ handlerutil.RespondWithJSON(w, http.StatusOK, out)
+}
+
+func (h *smtpHTTPHandler) handleMessageMeta(w http.ResponseWriter, r *http.Request, msg *ReceivedMessage) {
+ handlerutil.RespondWithJSON(w, http.StatusOK, buildMessageFullMeta(msg))
+}
+
+func (h *smtpHTTPHandler) handleMessageRaw(w http.ResponseWriter, r *http.Request, msg *ReceivedMessage) {
+ w.Header().Set("Content-Type", "message/rfc822")
+ w.Write(msg.RawMessageData())
+}
+
+func (h *smtpHTTPHandler) handleMultipartIndex(w http.ResponseWriter, r *http.Request, c *ReceivedContent) {
+ headerSearchRxs, err := extractSearchRegexes(w, r.URL.Query(), "header")
+ if err != nil {
+ handlerutil.RespondWithErr(w, http.StatusBadRequest, err)
+ return
+ }
+
+ multiparts := c.Multiparts()
+ if len(headerSearchRxs) > 0 {
+ multiparts = SearchByHeader(multiparts, headerSearchRxs...)
+ }
+
+ handlerutil.RespondWithJSON(w, http.StatusOK, buildMultipartIndex(multiparts))
+}
+
+func (h *smtpHTTPHandler) handleMultipartMeta(
+ w http.ResponseWriter, r *http.Request, part *ReceivedPart,
+) {
+ handlerutil.RespondWithJSON(w, http.StatusOK, buildMultipartMeta(part))
+}
+
+func (h *smtpHTTPHandler) handlePostMessage(w http.ResponseWriter, r *http.Request) {
+ maxMessageSize := h.server.maxMessageSize
+ if maxMessageSize == 0 {
+ maxMessageSize = DefaultMaxMessageSize
+ }
+
+ if r.Method != http.MethodPost {
+ handlerutil.RespondWithErr(
+ w, http.StatusMethodNotAllowed,
+ fmt.Errorf("postmessage only accepts POST requests"),
+ )
+ return
+ }
+
+ rawMessageData, err := io.ReadAll(io.LimitReader(r.Body, maxMessageSize))
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusBadRequest,
+ fmt.Errorf("error reading body: %w", err),
+ )
+ return
+ }
+
+ // Ensure line endings are CRLF
+ rawMessageData = bytes.ReplaceAll(rawMessageData, []byte("\r\n"), []byte("\n"))
+ rawMessageData = bytes.ReplaceAll(rawMessageData, []byte("\n"), []byte("\r\n"))
+
+ parsedRfcMsg, err := mail.ReadMessage(bytes.NewReader(rawMessageData))
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusBadRequest,
+ fmt.Errorf("postmessage could not parse message: %w", err),
+ )
+ return
+ }
+
+ from := parsedRfcMsg.Header.Get("From")
+ rcptTo := parsedRfcMsg.Header["To"]
+ receivedAt := time.Now()
+
+ msg, err := NewReceivedMessageFromParsed(
+ 0, // the index will be overriden by Server.AppendMessage below
+ from, rcptTo, rawMessageData, receivedAt, maxMessageSize,
+ parsedRfcMsg,
+ )
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusBadRequest,
+ fmt.Errorf("postmessage could not build ReceivedMessage: %w", err),
+ )
+ return
+ }
+
+ h.server.AppendMessage(msg)
+
+ w.WriteHeader(http.StatusOK)
+}
+
+// retrieveMessage attempts to retrieve the message referenced by the given index (still in string
+// form at this point). If the index could not be read or the message could not be retrieved,
+// an according error message will be returned via HTTP.
+func (h *smtpHTTPHandler) retrieveMessage(w http.ResponseWriter, sIdx string) (*ReceivedMessage, bool) {
+ idx, err := strconv.Atoi(sIdx)
+ if err != nil {
+ handlerutil.RespondWithErr(
+ w, http.StatusBadRequest,
+ fmt.Errorf("could not parse message index: %w", err),
+ )
+ return nil, false
+ }
+
+ msg, err := h.server.ReceivedMessage(idx)
+ if err != nil {
+ handlerutil.RespondWithErr(w, http.StatusNotFound, err)
+ return nil, false
+ }
+
+ return msg, true
+}
+
+func buildContentMeta(c *ReceivedContent) map[string]any {
+ contentTypeParams := c.ContentTypeParams()
+ if contentTypeParams == nil {
+ // Returning an empty map instead of null more closely resembles the semantics
+ // of contentTypeParams.
+ contentTypeParams = make(map[string]string)
+ }
+
+ out := map[string]any{
+ "bodySize": len(c.Body()),
+ "isMultipart": c.IsMultipart(),
+ "contentType": c.ContentType(),
+ "contentTypeParams": contentTypeParams,
+ }
+
+ headers := make(map[string]any)
+ for k, v := range c.Headers() {
+ headers[k] = v
+ }
+ out["headers"] = headers
+
+ if c.IsMultipart() {
+ multipartIndex := buildMultipartIndex(c.Multiparts())
+ for k, v := range multipartIndex {
+ out[k] = v
+ }
+ }
+
+ return out
+}
+
+func buildMessageBasicMeta(msg *ReceivedMessage) map[string]any {
+ content := msg.Content()
+
+ out := map[string]any{
+ "idx": msg.Index(),
+ "isMultipart": content.IsMultipart(),
+ "receivedAt": msg.ReceivedAt(),
+ "smtpFrom": msg.SmtpFrom(),
+ "smtpRcptTo": msg.SmtpRcptTo(),
+ }
+
+ from, ok := content.Headers()["From"]
+ if ok {
+ out["from"] = from
+ }
+
+ to, ok := content.Headers()["To"]
+ if ok {
+ out["to"] = to
+ }
+
+ subject, ok := content.Headers()["Subject"]
+ if ok && len(subject) == 1 {
+ out["subject"] = subject[0]
+ }
+
+ return out
+}
+
+func buildMessageFullMeta(msg *ReceivedMessage) map[string]any {
+ out := buildMessageBasicMeta(msg)
+ contentMeta := buildContentMeta(msg.Content())
+
+ for k, v := range contentMeta {
+ out[k] = v
+ }
+
+ return out
+}
+
+func buildMultipartIndex(parts []*ReceivedPart) map[string]any {
+ multipartsOut := make([]any, len(parts))
+
+ for i, part := range parts {
+ multipartsOut[i] = buildMultipartMeta(part)
+ }
+
+ out := make(map[string]any)
+ out["multipartsCount"] = len(parts)
+ out["multiparts"] = multipartsOut
+
+ return out
+}
+
+func buildMultipartMeta(part *ReceivedPart) map[string]any {
+ out := map[string]any{
+ "idx": part.Index(),
+ }
+
+ contentMeta := buildContentMeta(part.Content())
+
+ for k, v := range contentMeta {
+ out[k] = v
+ }
+
+ return out
+}
+
+// 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) {
+ filteredParams, ok := queryParams[paramName]
+ if ok {
+ if len(filteredParams) != 1 {
+ return nil, fmt.Errorf(
+ "expected 1 %q query parameter, got %d (use JSON array for multiple queries)",
+ paramName, len(filteredParams),
+ )
+ }
+
+ var searchParams []string
+ err := json.Unmarshal([]byte(filteredParams[0]), &searchParams)
+ if err != nil {
+ searchParams = []string{filteredParams[0]}
+ }
+
+ 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,
+ )
+ }
+
+ out[i] = re
+ }
+
+ return out, nil
+ }
+
+ return nil, nil
+}
diff --git a/internal/smtp/message.go b/internal/smtp/message.go
new file mode 100644
index 00000000..3b47dc42
--- /dev/null
+++ b/internal/smtp/message.go
@@ -0,0 +1,296 @@
+package smtp
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "mime/quotedprintable"
+ "net/mail"
+ "strings"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+// ReceivedMessage contains a single email message as received via SMTP.
+type ReceivedMessage struct {
+ index int
+
+ smtpFrom string
+ smtpRcptTo []string
+ rawMessageData []byte
+ receivedAt time.Time
+
+ content *ReceivedContent
+}
+
+// ReceivedPart contains a single part of a multipart message as received
+// via SMTP.
+type ReceivedPart struct {
+ index int
+
+ content *ReceivedContent
+}
+
+// ReceivedContent contains the contents of an email message or multipart part.
+type ReceivedContent struct {
+ headers map[string][]string
+ body []byte
+
+ contentType string
+ contentTypeParams map[string]string
+ isMultipart bool
+
+ multiparts []*ReceivedPart
+}
+
+// ContentHaver makes it easier to write algorithms over types that have an
+// email message and/or multipart content.
+type ContentHaver interface {
+ Content() *ReceivedContent
+}
+
+// NewReceivedMessage parses a raw message as received via SMTP into a
+// ReceivedMessage struct.
+//
+// Incoming data is truncated after the given maximum message size.
+// If a maxMessageSize of 0 is given, this function will default to using
+// DefaultMaxMessageSize.
+func NewReceivedMessage(
+ index int,
+ from string, rcptTo []string, rawMessageData []byte, receivedAt time.Time,
+ maxMessageSize int64,
+) (*ReceivedMessage, error) {
+ if maxMessageSize == 0 {
+ maxMessageSize = DefaultMaxMessageSize
+ }
+
+ parsedMsg, err := mail.ReadMessage(io.LimitReader(bytes.NewReader(rawMessageData), maxMessageSize))
+ if err != nil {
+ return nil, fmt.Errorf("could not parse message: %w", err)
+ }
+
+ return NewReceivedMessageFromParsed(
+ index, from, rcptTo, rawMessageData, receivedAt, maxMessageSize, parsedMsg,
+ )
+}
+
+// NewReceivedMessageFromParsed creates a ReceivedMessage from an already parsed email.
+//
+// See the documentation of NewReceivedMessage for more details.
+func NewReceivedMessageFromParsed(
+ index int,
+ from string, rcptTo []string, rawMessageData []byte, receivedAt time.Time,
+ maxMessageSize int64, parsedMsg *mail.Message,
+) (*ReceivedMessage, error) {
+ content, err := NewReceivedContent(parsedMsg.Header, parsedMsg.Body, maxMessageSize)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse content: %w", err)
+ }
+
+ msg := &ReceivedMessage{
+ index: index,
+ smtpFrom: from,
+ smtpRcptTo: rcptTo,
+ rawMessageData: rawMessageData,
+ receivedAt: receivedAt,
+ content: content,
+ }
+
+ return msg, nil
+}
+
+// NewReceivedPart parses a MIME multipart part into a ReceivedPart struct.
+//
+// maxMessageSize is passed through to NewReceivedContent (see its documentation for details).
+func NewReceivedPart(index int, p *multipart.Part, maxMessageSize int64) (*ReceivedPart, error) {
+ content, err := NewReceivedContent(p.Header, p, maxMessageSize)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse content: %w", err)
+ }
+
+ part := &ReceivedPart{
+ index: index,
+ content: content,
+ }
+
+ return part, nil
+}
+
+// NewReceivedContent parses a message or part headers and body into a ReceivedContent struct.
+//
+// Incoming data is truncated after the given maximum message size.
+// If a maxMessageSize of 0 is given, this function will default to using
+// DefaultMaxMessageSize.
+func NewReceivedContent(
+ headers map[string][]string, bodyReader io.Reader, maxMessageSize int64,
+) (*ReceivedContent, error) {
+ if maxMessageSize == 0 {
+ maxMessageSize = DefaultMaxMessageSize
+ }
+
+ headers = preprocessHeaders(headers)
+
+ body, err := io.ReadAll(wrapBodyReader(bodyReader, headers, maxMessageSize))
+ if err != nil {
+ return nil, fmt.Errorf("could not read body: %w", err)
+ }
+
+ content := &ReceivedContent{
+ headers: headers,
+ body: body,
+ }
+
+ rawContentType, ok := headers["Content-Type"]
+ if ok && rawContentType[0] != "" && len(rawContentType) > 0 {
+ content.contentType, content.contentTypeParams, err = mime.ParseMediaType(rawContentType[0])
+ if err != nil {
+ return nil, fmt.Errorf("could not parse Content-Type: %w", err)
+ }
+
+ // case-sensitive comparison of the content type is permitted here,
+ // since mime.ParseMediaType is documented to return the media type
+ // in lower case.
+ content.isMultipart = strings.HasPrefix(content.contentType, "multipart/")
+ }
+
+ if content.IsMultipart() {
+ boundary, ok := content.contentTypeParams["boundary"]
+ if !ok {
+ return nil, fmt.Errorf("encountered multipart message without defined boundary")
+ }
+
+ r := multipart.NewReader(bytes.NewReader(content.body), boundary)
+
+ for i := 0; ; i++ {
+ rawPart, err := r.NextRawPart()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ } else {
+ return nil, fmt.Errorf("could not read multipart: %w", err)
+ }
+ }
+
+ part, err := NewReceivedPart(i, rawPart, maxMessageSize)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse message part: %w", err)
+ }
+
+ content.multiparts = append(content.multiparts, part)
+ }
+ }
+
+ return content, nil
+}
+
+// preprocessHeaders decodes header values that were encoded according to RFC2047.
+func preprocessHeaders(headers map[string][]string) map[string][]string {
+ var decoder mime.WordDecoder
+
+ out := make(map[string][]string)
+
+ for k, vs := range headers {
+ out[k] = make([]string, len(vs))
+
+ for i := range vs {
+ dec, err := decoder.DecodeHeader(vs[i])
+ if err != nil {
+ logrus.Warn("could not decode Q-Encoding in header:", err)
+ } else {
+ out[k][i] = dec
+ }
+ }
+ }
+
+ return out
+}
+
+// wrapBodyReader wraps the reader for a message / part body with size
+// limitation and quoted-printable/base64 decoding (the latter based on
+// the Content-Transfer-Encoding header, if any is set).
+func wrapBodyReader(r io.Reader, headers map[string][]string, maxMessageSize int64) io.Reader {
+ r = io.LimitReader(r, maxMessageSize)
+
+ enc, ok := headers["Content-Transfer-Encoding"]
+ if ok {
+ if len(enc) != 1 {
+ logrus.Error("Content-Transfer-Encoding must have exactly one value")
+ }
+
+ switch enc[0] {
+ case "base64":
+ r = base64.NewDecoder(base64.StdEncoding, r)
+ case "quoted-printable":
+ r = quotedprintable.NewReader(r)
+ default:
+ logrus.Errorf("encountered unknown Content-Transfer-Encoding %q", enc)
+ }
+ }
+
+ return r
+}
+
+// =======
+// Getters
+// =======
+
+func (c *ReceivedContent) ContentType() string {
+ return c.contentType
+}
+
+func (c *ReceivedContent) ContentTypeParams() map[string]string {
+ return c.contentTypeParams
+}
+
+func (c *ReceivedContent) Body() []byte {
+ return c.body
+}
+
+func (c *ReceivedContent) Headers() map[string][]string {
+ return c.headers
+}
+
+func (c *ReceivedContent) IsMultipart() bool {
+ return c.isMultipart
+}
+
+func (c *ReceivedContent) Multiparts() []*ReceivedPart {
+ return c.multiparts
+}
+
+func (m *ReceivedMessage) Content() *ReceivedContent {
+ return m.content
+}
+
+func (m *ReceivedMessage) Index() int {
+ return m.index
+}
+
+func (m *ReceivedMessage) RawMessageData() []byte {
+ return m.rawMessageData
+}
+
+func (m *ReceivedMessage) ReceivedAt() time.Time {
+ return m.receivedAt
+}
+
+func (m *ReceivedMessage) SmtpFrom() string {
+ return m.smtpFrom
+}
+
+func (m *ReceivedMessage) SmtpRcptTo() []string {
+ return m.smtpRcptTo
+}
+
+func (p *ReceivedPart) Content() *ReceivedContent {
+ return p.content
+}
+
+func (p *ReceivedPart) Index() int {
+ return p.index
+}
diff --git a/internal/smtp/search.go b/internal/smtp/search.go
new file mode 100644
index 00000000..8fa38e43
--- /dev/null
+++ b/internal/smtp/search.go
@@ -0,0 +1,51 @@
+package smtp
+
+import (
+ "fmt"
+ "regexp"
+)
+
+// 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 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 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 {
+ header := fmt.Sprintf("%s: %s", k, v)
+ if re.MatchString(header) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
diff --git a/internal/smtp/server.go b/internal/smtp/server.go
new file mode 100644
index 00000000..1a7c2ee2
--- /dev/null
+++ b/internal/smtp/server.go
@@ -0,0 +1,184 @@
+package smtp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/emersion/go-smtp"
+ "github.com/sirupsen/logrus"
+)
+
+const DefaultMaxMessageSize = 30 * 1024 * 1024 // 30MiB
+
+// Server contains a basic SMTP server for testing purposes.
+//
+// It will accept incoming messages and save them to an internal list of
+// received messages, which can be queried using the appropriate methods
+// of Server.
+type Server struct {
+ server *smtp.Server
+ receivedMessages []*ReceivedMessage
+
+ maxMessageSize int64
+
+ mutex sync.RWMutex
+
+ clock func() time.Time // making clock mockable for unit testing
+}
+
+type session struct {
+ server *Server
+ conn *smtp.Conn
+
+ from string
+ rcptTo []string
+}
+
+// NewServer creates a new testing SMTP server.
+//
+// The new server will listen at the provided address.
+//
+// Incoming messages are truncated after the given maximum message size.
+// If a maxMessageSize of 0 is given, Server will default to using
+// DefaultMaxMessageSize.
+func NewServer(addr string, maxMessageSize int64) *Server {
+ if maxMessageSize == 0 {
+ maxMessageSize = DefaultMaxMessageSize
+ }
+
+ server := &Server{
+ maxMessageSize: maxMessageSize,
+ clock: time.Now,
+ }
+
+ backend := smtp.BackendFunc(func(c *smtp.Conn) (smtp.Session, error) {
+ return newSession(server, c)
+ })
+
+ s := smtp.NewServer(backend)
+ s.Addr = addr
+ s.EnableSMTPUTF8 = true
+ s.EnableBINARYMIME = true
+
+ server.server = s
+
+ return server
+}
+
+// AppendMessage adds a custom message to the Server's storage.
+//
+// The index of the provided message will be updated to the index at which
+// it was actually inserted into the Server's storage.
+func (s *Server) AppendMessage(msg *ReceivedMessage) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ msg.index = len(s.receivedMessages)
+ s.receivedMessages = append(s.receivedMessages, msg)
+}
+
+// ListenAndServe runs the SMTP server. It will not return until the server is
+// shut down or otherwise aborts.
+func (s *Server) ListenAndServe() error {
+ return s.server.ListenAndServe()
+}
+
+// Shutdown shuts down the SMTP server that was previously started using
+// ListenAndServe.
+func (s *Server) Shutdown(ctx context.Context) error {
+ return s.server.Shutdown(ctx)
+}
+
+// ReceivedMessage returns a message that the server has retrieved
+// by its index in the list of received messages.
+func (s *Server) ReceivedMessage(idx int) (*ReceivedMessage, error) {
+ s.mutex.RLock()
+ defer s.mutex.RUnlock()
+
+ if idx >= len(s.receivedMessages) {
+ return nil, fmt.Errorf(
+ "Server does not contain message with index %d", idx,
+ )
+ }
+
+ return s.receivedMessages[idx], nil
+}
+
+// ReceivedMessages returns the list of all messages that the server has
+// retrieved.
+func (s *Server) ReceivedMessages() []*ReceivedMessage {
+ s.mutex.RLock()
+ defer s.mutex.RUnlock()
+
+ // We copy the slice to avoid race conditions when the receivedMessages slice is updated.
+ // It's just a slice of pointers, so it should be relatively lightweight.
+ view := make([]*ReceivedMessage, len(s.receivedMessages))
+ copy(view, s.receivedMessages)
+
+ return view
+}
+
+func newSession(server *Server, c *smtp.Conn) (smtp.Session, error) {
+ return &session{
+ server: server,
+ conn: c,
+ }, nil
+}
+
+// Implements smtp.Session's Data method.
+func (s *session) Data(r io.Reader) error {
+ rawData, err := io.ReadAll(io.LimitReader(r, s.server.maxMessageSize))
+ if err != nil {
+ return fmt.Errorf("could not read mail data from SMTP: %w", err)
+ }
+
+ s.server.mutex.Lock()
+ defer s.server.mutex.Unlock()
+
+ idx := len(s.server.receivedMessages)
+ now := s.server.clock()
+
+ logrus.Infof("SMTP: Receiving message from %s to %v at %v", s.from, s.rcptTo, now)
+ msg, err := NewReceivedMessage(
+ idx, s.from, s.rcptTo, rawData, now, s.server.maxMessageSize,
+ )
+ if err != nil {
+ errWrapped := fmt.Errorf("error constructing ReceivedMessage in SMTP server: %w", err)
+ logrus.Error("SMTP:", errWrapped) // this is logged in our server
+ return errWrapped // this is returned via SMTP
+ }
+ logrus.Infof("SMTP: Reception successful")
+
+ s.server.receivedMessages = append(s.server.receivedMessages, msg)
+
+ return nil
+}
+
+// Implements smtp.Session's Logout method.
+func (s *session) Logout() error {
+ s.Reset()
+ return nil
+}
+
+// Implements smtp.Session's Mail method.
+func (s *session) Mail(from string, opts *smtp.MailOptions) error {
+ // opts are currently ignored
+ s.from = from
+ return nil
+}
+
+// Implements smtp.Session's Rcpt method.
+func (s *session) Rcpt(to string, opts *smtp.RcptOptions) error {
+ // opts are currently ignored
+ s.rcptTo = append(s.rcptTo, to)
+ return nil
+}
+
+// Implements smtp.Session's Reset method.
+func (s *session) Reset() {
+ s.from = ""
+ s.rcptTo = nil
+}
diff --git a/internal/smtp/smtp_test.go b/internal/smtp/smtp_test.go
new file mode 100644
index 00000000..00df9918
--- /dev/null
+++ b/internal/smtp/smtp_test.go
@@ -0,0 +1,976 @@
+package smtp
+
+import (
+ "context"
+ _ "embed"
+ "net"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+//go:embed smtp_testsession.txt
+var smtpSession string
+
+var testTime time.Time = time.Now()
+var server *Server = runTestSession()
+
+func TestMessageParsing(t *testing.T) {
+ expectedMessages := buildExpectedMessages()
+
+ require.Equal(t, len(expectedMessages), len(server.receivedMessages), "number of received messages")
+
+ for i := range expectedMessages {
+ assertMessageEqual(t, expectedMessages[i], server.receivedMessages[i])
+ }
+}
+
+func TestMessageSearch(t *testing.T) {
+ testCases := []struct {
+ queries []string
+ expectedIndices []int
+ }{
+ {
+ queries: []string{``},
+ expectedIndices: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
+ },
+ {
+ queries: []string{
+ `Content`,
+ `Content-Type`,
+ `^Content`,
+ `^Content-Type`,
+ `Content-Type:.*`,
+ `^Content-Type:.*$`,
+ },
+ expectedIndices: []int{1, 2, 3, 4, 5, 8, 9},
+ },
+ {
+ queries: []string{
+ `^Transfer`,
+ `X-Funky-Header`,
+ },
+ expectedIndices: []int{},
+ },
+ {
+ queries: []string{
+ `Transfer`,
+ `Content-Transfer-Encoding`,
+ `^Content-Transfer`,
+ `Content-Transfer-Encoding:.*`,
+ `^Content-Transfer-Encoding:.*$`,
+ },
+ expectedIndices: []int{3, 4},
+ },
+ {
+ queries: []string{
+ `base64`,
+ `Content-Transfer-Encoding: base64`,
+ `^Content-Transfer-Encoding: base64$`,
+ },
+ expectedIndices: []int{3},
+ },
+ {
+ queries: []string{
+ `Subject: .*[äöüÄÖÜ]`,
+ `^Subject: .*[äöüÄÖÜ]`,
+ `Tästmail`,
+ },
+ expectedIndices: []int{6, 7},
+ },
+ }
+
+ for i := range testCases {
+ testCase := testCases[i]
+
+ for j := range testCase.queries {
+ query := testCase.queries[j]
+ t.Run(query, func(t *testing.T) {
+ re := regexp.MustCompile(query)
+ actual := SearchByHeader(server.ReceivedMessages(), re)
+
+ actualIndices := make([]int, len(actual))
+ for ai, av := range actual {
+ actualIndices[ai] = av.index
+ }
+ assert.ElementsMatch(t, testCase.expectedIndices, actualIndices)
+ })
+ }
+ }
+}
+
+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.
+
+ testCases := []struct {
+ queries []string
+ expectedIndices []int
+ }{
+ {
+ queries: []string{
+ "From",
+ "Testmail",
+ },
+ expectedIndices: []int{},
+ },
+ {
+ queries: []string{
+ "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",
+ "X-Funky-Header: K[äöü]se",
+ "^X-Funky-Header: Käse$",
+ "Content-Transfer-Encoding: quoted-printable",
+ "^Content-Transfer.* quoted",
+ "quoted-printable",
+ },
+ expectedIndices: []int{1},
+ },
+ {
+ queries: []string{
+ "X-Funky-Header: Tästmail mit Ümlauten im Header",
+ "X-Funky-Header: .*Ü",
+ "Content-Transfer-Encoding: base64",
+ "^Content-Transfer.* base64",
+ "base64",
+ },
+ expectedIndices: []int{0},
+ },
+ }
+
+ for i := range testCases {
+ testCase := testCases[i]
+
+ for j := range testCase.queries {
+ query := testCase.queries[j]
+ t.Run(query, func(t *testing.T) {
+ re := regexp.MustCompile(query)
+
+ msg, err := server.ReceivedMessage(8)
+ require.NoError(t, err)
+
+ actual := SearchByHeader(msg.Content().Multiparts(), re)
+
+ actualIndices := make([]int, len(actual))
+ for ai, av := range actual {
+ actualIndices[ai] = av.index
+ }
+ assert.ElementsMatch(t, testCase.expectedIndices, actualIndices)
+ })
+ }
+ }
+}
+
+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))
+
+ for k, v := range expected {
+ if assert.Contains(t, actual, k) {
+ assert.ElementsMatch(t, v, actual[k])
+ }
+ }
+}
+
+func assertMessageEqual(t *testing.T, expected, actual *ReceivedMessage) {
+ assert.Equal(t, expected.index, actual.index)
+ assert.Equal(t, expected.smtpFrom, actual.smtpFrom)
+ assert.ElementsMatch(t, expected.smtpRcptTo, actual.smtpRcptTo)
+ assert.Equal(t, expected.rawMessageData, actual.rawMessageData)
+ assert.Equal(t, expected.receivedAt, actual.receivedAt)
+
+ assertContentEqual(t, expected.content, actual.content)
+}
+
+func assertMultipartEqual(t *testing.T, expected, actual *ReceivedPart) {
+ assert.Equal(t, expected.index, actual.index)
+ assertContentEqual(t, expected.content, actual.content)
+}
+
+func assertContentEqual(t *testing.T, expected, actual *ReceivedContent) {
+ assert.Equal(t, expected.body, actual.body)
+ assert.Equal(t, expected.contentType, actual.contentType)
+ assert.Equal(t, expected.contentTypeParams, actual.contentTypeParams)
+ assert.Equal(t, expected.isMultipart, actual.isMultipart)
+
+ assertHeadersEqual(t, expected.headers, actual.headers)
+
+ if assert.Equal(t, len(expected.multiparts), len(actual.multiparts)) {
+ for i, m := range expected.multiparts {
+ assertMultipartEqual(t, m, actual.multiparts[i])
+ }
+ }
+}
+
+// runTestSession starts a Server, runs a pre-recorded SMTP session,
+// stops the Server and returns the Server struct.
+func runTestSession() *Server {
+ addr := ":9925"
+
+ smtpSrc := strings.ReplaceAll(smtpSession, "\n", "\r\n")
+
+ server := NewServer(addr, 0)
+ server.clock = func() time.Time { return testTime }
+ go server.ListenAndServe()
+ defer server.Shutdown(context.Background())
+
+ // give the server some time to open
+ time.Sleep(time.Second)
+
+ conn, err := net.Dial("tcp", addr)
+ if err != nil {
+ panic(err)
+ }
+ defer conn.Close()
+
+ _, err = conn.Write([]byte(smtpSrc))
+ if err != nil {
+ panic(err)
+ }
+
+ // give the server some time to process
+ time.Sleep(time.Second)
+
+ return server
+}
+
+func buildExpectedMessages() []*ReceivedMessage {
+ messages := []*ReceivedMessage{
+ {
+ index: 0,
+ smtpFrom: "testsender@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver@programmfabrik.de"},
+ rawMessageData: []byte(`From: testsender@programmfabrik.de
+To: testreceiver@programmfabrik.de
+
+Hello World!
+A simple plain text test mail.`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "From": {"testsender@programmfabrik.de"},
+ "To": {"testreceiver@programmfabrik.de"},
+ },
+ body: []byte(`Hello World!
+A simple plain text test mail.`),
+ },
+ },
+ {
+ index: 1,
+ smtpFrom: "testsender2@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver2@programmfabrik.de"},
+ rawMessageData: []byte(`MIME-Version: 1.0
+From: testsender2@programmfabrik.de
+To: testreceiver2@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+Preamble is ignored.
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/plain; charset=utf-8
+
+Some plain text
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/html; charset=utf-8
+
+Some text in HTML format.
+--d36c3118be4745f9a1cb4556d11fe92d--
+
+Trailing text is ignored.`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Mime-Version": {"1.0"},
+ "From": {"testsender2@programmfabrik.de"},
+ "To": {"testreceiver2@programmfabrik.de"},
+ "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"},
+ "Subject": {"Example Message"},
+ "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`},
+ },
+ body: []byte(`Preamble is ignored.
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/plain; charset=utf-8
+
+Some plain text
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/html; charset=utf-8
+
+Some text in HTML format.
+--d36c3118be4745f9a1cb4556d11fe92d--
+
+Trailing text is ignored.`),
+ contentType: "multipart/mixed",
+ contentTypeParams: map[string]string{
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d",
+ },
+ isMultipart: true,
+ multiparts: []*ReceivedPart{
+ {
+ index: 0,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ },
+ body: []byte(`Some plain text`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 1,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/html; charset=utf-8"},
+ },
+ body: []byte(`Some text in HTML format.`),
+ contentType: "text/html",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ index: 2,
+ smtpFrom: "testsender3@programmfabrik.de",
+ 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: äöüß`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ 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: äöüß`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 3,
+ smtpFrom: "testsender4@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver4@programmfabrik.de"},
+ rawMessageData: []byte(`From: testsender4@programmfabrik.de
+To: testreceiver4@programmfabrik.de
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "From": {"testsender4@programmfabrik.de"},
+ "To": {"testreceiver4@programmfabrik.de"},
+ "Content-Type": {"text/plain; charset=utf-8"},
+ "Content-Transfer-Encoding": {"base64"},
+ },
+ body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß
+`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 4,
+ smtpFrom: "testsender5@programmfabrik.de",
+ 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
+
+Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
+d-printable.`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ 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"},
+ },
+ body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 5,
+ smtpFrom: "testsender6@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver6@programmfabrik.de"},
+ rawMessageData: []byte(`MIME-Version: 1.0
+From: testsender6@programmfabrik.de
+To: testreceiver6@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
+d-printable.
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Mime-Version": {"1.0"},
+ "From": {"testsender6@programmfabrik.de"},
+ "To": {"testreceiver6@programmfabrik.de"},
+ "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"},
+ "Subject": {"Example Message"},
+ "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`},
+ },
+ body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
+d-printable.
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ contentType: "multipart/mixed",
+ contentTypeParams: map[string]string{
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d",
+ },
+ isMultipart: true,
+ multiparts: []*ReceivedPart{
+ {
+ index: 0,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ "Content-Transfer-Encoding": {"base64"},
+ },
+ body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß
+`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 1,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ "Content-Transfer-Encoding": {"quoted-printable"},
+ },
+ body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ index: 6,
+ smtpFrom: "tästsender7@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver7@programmfabrik.de"},
+ rawMessageData: []byte(`From: tästsender7@programmfabrik.de
+To: testreceiver7@programmfabrik.de
+Subject: Tästmail mit Ümlauten im Header
+
+Hello World!
+A simple plain text test mail.`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "From": {"tästsender7@programmfabrik.de"},
+ "To": {"testreceiver7@programmfabrik.de"},
+ "Subject": {"Tästmail mit Ümlauten im Header"},
+ },
+ body: []byte(`Hello World!
+A simple plain text test mail.`),
+ },
+ },
+ {
+ index: 7,
+ smtpFrom: "testsender8@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver8@programmfabrik.de"},
+ rawMessageData: []byte(`From: =?utf-8?q?t=C3=A4stsender8=40programmfabrik=2Ede?=
+To: testreceiver8@programmfabrik.de
+Subject: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?=
+
+Hello World!
+A simple plain text test mail.`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "From": {"tästsender8@programmfabrik.de"},
+ "To": {"testreceiver8@programmfabrik.de"},
+ "Subject": {"Tästmail mit Ümlauten im Header"},
+ },
+ body: []byte(`Hello World!
+A simple plain text test mail.`),
+ },
+ },
+ {
+ index: 8,
+ smtpFrom: "testsender9@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver9@programmfabrik.de"},
+ rawMessageData: []byte(`MIME-Version: 1.0
+From: testsender9@programmfabrik.de
+To: testreceiver9@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+X-Funky-Header: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?=
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+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
+
+Foo
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+X-Funky-Header: Phase
+
+Foobar.
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Mime-Version": {"1.0"},
+ "From": {"testsender9@programmfabrik.de"},
+ "To": {"testreceiver9@programmfabrik.de"},
+ "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"},
+ "Subject": {"Example Message"},
+ "Content-Type": {`multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"`},
+ },
+ body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+X-Funky-Header: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?=
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+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
+
+Foo
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+X-Funky-Header: Phase
+
+Foobar.
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ contentType: "multipart/mixed",
+ contentTypeParams: map[string]string{
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d",
+ },
+ isMultipart: true,
+ multiparts: []*ReceivedPart{
+ {
+ index: 0,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ "Content-Transfer-Encoding": {"base64"},
+ "X-Funky-Header": {"Tästmail mit Ümlauten im Header"},
+ },
+ body: []byte(`Eine base64-enkodierte Testmail mit nicht-ASCII-Zeichen: äöüß
+`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 1,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ "Content-Transfer-Encoding": {"quoted-printable"},
+ "X-Funky-Header": {"Käse"},
+ },
+ body: []byte(`Noch eine Testmail mit äöüß, diesmal enkodiert in quoted-printable.`),
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 2,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/html; charset=utf-8"},
+ "X-Funky-Header": {"Nase"},
+ },
+ body: []byte(`Foo`),
+ 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",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ index: 9,
+ smtpFrom: "testsender10@programmfabrik.de",
+ smtpRcptTo: []string{"testreceiver10@programmfabrik.de"},
+ rawMessageData: []byte(`MIME-Version: 1.0
+From: testsender10@programmfabrik.de
+To: testreceiver10@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Nested Message
+Content-type: multipart/alternative; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+
+Some plain text for clients that don't support nested multipart.
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
+
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/plain; charset=ascii
+
+This is the first subpart.
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/html; charset=utf-8
+
+This is the second subpart.
+--710d3e95c17247d4bb35d621f25e094e--
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ receivedAt: testTime,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Mime-Version": {"1.0"},
+ "From": {"testsender10@programmfabrik.de"},
+ "To": {"testreceiver10@programmfabrik.de"},
+ "Date": {"Tue, 25 Jun 2024 11:15:57 +0200"},
+ "Subject": {"Example Nested Message"},
+ "Content-Type": {`multipart/alternative; boundary="d36c3118be4745f9a1cb4556d11fe92d"`},
+ },
+ body: []byte(`--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+
+Some plain text for clients that don't support nested multipart.
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
+
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/plain; charset=ascii
+
+This is the first subpart.
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/html; charset=utf-8
+
+This is the second subpart.
+--710d3e95c17247d4bb35d621f25e094e--
+--d36c3118be4745f9a1cb4556d11fe92d--`),
+ contentType: "multipart/alternative",
+ contentTypeParams: map[string]string{
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d",
+ },
+ isMultipart: true,
+ multiparts: []*ReceivedPart{
+ {
+ index: 0,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=utf-8"},
+ },
+ body: []byte(`Some plain text for clients that don't support nested multipart.`),
+
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ {
+ index: 1,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {`multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"`},
+ },
+ body: []byte(`--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/plain; charset=ascii
+
+This is the first subpart.
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/html; charset=utf-8
+
+This is the second subpart.
+--710d3e95c17247d4bb35d621f25e094e--`),
+
+ contentType: "multipart/mixed",
+ contentTypeParams: map[string]string{
+ "boundary": "710d3e95c17247d4bb35d621f25e094e",
+ },
+
+ isMultipart: true,
+ multiparts: []*ReceivedPart{
+ {
+ index: 0,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/plain; charset=ascii"},
+ },
+ body: []byte(`This is the first subpart.`),
+
+ contentType: "text/plain",
+ contentTypeParams: map[string]string{
+ "charset": "ascii",
+ },
+ },
+ },
+ {
+ index: 1,
+ content: &ReceivedContent{
+ headers: map[string][]string{
+ "Content-Type": {"text/html; charset=utf-8"},
+ },
+ body: []byte(`This is the second subpart.`),
+
+ contentType: "text/html",
+ contentTypeParams: map[string]string{
+ "charset": "utf-8",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ // the following calls pre-format the test data defined above to match
+ // the actual output as produced when sending via SMTP and receiving again,
+ // with regards to things like line endings and trailing empty lines.
+ //
+ // Directly putting it into the testdata above would fail when checking into
+ // source control, if source control normalizes line endings.
+
+ for _, m := range messages {
+ m.rawMessageData = formatRaw(m.rawMessageData)
+ m.rawMessageData = appendCRLF(m.rawMessageData)
+
+ // Format message body only if not in base64 transfer encoding
+ cte, ok := m.content.headers["Content-Transfer-Encoding"]
+ if !ok || len(cte) != 1 || cte[0] != "base64" {
+ m.content.body = formatRaw(m.content.body)
+ m.content.body = appendCRLF(m.content.body)
+ }
+
+ formatMultipartContent(m.content.multiparts)
+ }
+
+ return messages
+}
+
+func appendCRLF(b []byte) []byte {
+ return []byte(string(b) + "\r\n")
+}
+
+func formatRaw(b []byte) []byte {
+ return []byte(strings.ReplaceAll(string(b), "\n", "\r\n"))
+}
+
+func formatMultipartContent[T ContentHaver](parts []T) {
+ for _, p := range parts {
+ content := p.Content()
+
+ // Format multipart body only if not in base64 transfer encoding
+ cte, ok := content.headers["Content-Transfer-Encoding"]
+ if !ok || len(cte) != 1 || cte[0] != "base64" {
+ content.body = formatRaw(content.body)
+ }
+
+ // Multiparts do not add a trailing CRLF
+
+ if len(content.multiparts) > 0 {
+ formatMultipartContent(content.multiparts)
+ }
+ }
+}
diff --git a/internal/smtp/smtp_testsession.txt b/internal/smtp/smtp_testsession.txt
new file mode 100644
index 00000000..567082c6
--- /dev/null
+++ b/internal/smtp/smtp_testsession.txt
@@ -0,0 +1,177 @@
+EHLO test.programmfabrik.de
+MAIL FROM: testsender@programmfabrik.de
+RCPT TO: testreceiver@programmfabrik.de
+DATA
+From: testsender@programmfabrik.de
+To: testreceiver@programmfabrik.de
+
+Hello World!
+A simple plain text test mail.
+.
+MAIL FROM: testsender2@programmfabrik.de
+RCPT TO: testreceiver2@programmfabrik.de
+DATA
+MIME-Version: 1.0
+From: testsender2@programmfabrik.de
+To: testreceiver2@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+Preamble is ignored.
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/plain; charset=utf-8
+
+Some plain text
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-type: text/html; charset=utf-8
+
+Some text in HTML format.
+--d36c3118be4745f9a1cb4556d11fe92d--
+
+Trailing text is ignored.
+.
+MAIL FROM: testsender3@programmfabrik.de
+RCPT TO: testreceiver3@programmfabrik.de
+DATA
+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: äöüß
+.
+MAIL FROM: testsender4@programmfabrik.de
+RCPT TO: testreceiver4@programmfabrik.de
+DATA
+From: testsender4@programmfabrik.de
+To: testreceiver4@programmfabrik.de
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+.
+MAIL FROM: testsender5@programmfabrik.de
+RCPT TO: testreceiver5@programmfabrik.de
+DATA
+From: testsender5@programmfabrik.de
+To: testreceiver5@programmfabrik.de
+Subject: Example Message
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
+d-printable.
+.
+MAIL FROM: testsender6@programmfabrik.de
+RCPT TO: testreceiver6@programmfabrik.de
+DATA
+MIME-Version: 1.0
+From: testsender6@programmfabrik.de
+To: testreceiver6@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+Noch eine Testmail mit =C3=A4=C3=B6=C3=BC=C3=9F, diesmal enkodiert in quote=
+d-printable.
+--d36c3118be4745f9a1cb4556d11fe92d--
+.
+MAIL FROM: tästsender7@programmfabrik.de
+RCPT TO: testreceiver7@programmfabrik.de
+DATA
+From: tästsender7@programmfabrik.de
+To: testreceiver7@programmfabrik.de
+Subject: Tästmail mit Ümlauten im Header
+
+Hello World!
+A simple plain text test mail.
+.
+MAIL FROM: testsender8@programmfabrik.de
+RCPT TO: testreceiver8@programmfabrik.de
+DATA
+From: =?utf-8?q?t=C3=A4stsender8=40programmfabrik=2Ede?=
+To: testreceiver8@programmfabrik.de
+Subject: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?=
+
+Hello World!
+A simple plain text test mail.
+.
+MAIL FROM: testsender9@programmfabrik.de
+RCPT TO: testreceiver9@programmfabrik.de
+DATA
+MIME-Version: 1.0
+From: testsender9@programmfabrik.de
+To: testreceiver9@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Message
+Content-type: multipart/mixed; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+X-Funky-Header: =?utf-8?q?T=C3=A4stmail_mit_=C3=9Cmlauten_im_Header?=
+
+RWluZSBiYXNlNjQtZW5rb2RpZXJ0ZSBUZXN0bWFpbCBtaXQgbmljaHQtQVNDSUktWmVpY2hlbjog
+w6TDtsO8w58K
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+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
+
+Foo
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+X-Funky-Header: Phase
+
+Foobar.
+--d36c3118be4745f9a1cb4556d11fe92d--
+.
+MAIL FROM: testsender10@programmfabrik.de
+RCPT TO: testreceiver10@programmfabrik.de
+DATA
+MIME-Version: 1.0
+From: testsender10@programmfabrik.de
+To: testreceiver10@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Nested Message
+Content-type: multipart/alternative; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+
+Some plain text for clients that don't support nested multipart.
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
+
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/plain; charset=ascii
+
+This is the first subpart.
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/html; charset=utf-8
+
+This is the second subpart.
+--710d3e95c17247d4bb35d621f25e094e--
+--d36c3118be4745f9a1cb4556d11fe92d--
+.
+QUIT
+
diff --git a/main.go b/main.go
index 1b761678..8a3a469b 100644
--- a/main.go
+++ b/main.go
@@ -17,10 +17,10 @@ import (
)
var (
- reportFormat, reportFile, serverURL, httpServerReplaceHost string
- logNetwork, logDatastore, logVerbose, logTimeStamp, logShort, logCurl, stopOnFail bool
- rootDirectorys, singleTests []string
- limitRequest, limitResponse, reportStatsGroups uint
+ reportFormat, reportFile, serverURL, httpServerReplaceHost string
+ keepRunning, logNetwork, logDatastore, logVerbose, logTimeStamp, logShort, logCurl, stopOnFail bool
+ rootDirectorys, singleTests []string
+ limitRequest, limitResponse, reportStatsGroups uint
// set via -ldflags during build
buildCommit, buildTime, buildVersion string
)
@@ -85,6 +85,10 @@ func init() {
&logCurl, "curl-bash", false,
"Log network output as bash curl command")
+ testCMD.PersistentFlags().BoolVar(
+ &keepRunning, "keep-running", false,
+ "Before returning from each test suite, prompt for a keyboard interrupt")
+
testCMD.PersistentFlags().BoolVar(
&stopOnFail, "stop-on-fail", false,
"Stop execution of later test suites if a test suite fails")
diff --git a/pkg/lib/util/net.go b/pkg/lib/util/net.go
new file mode 100644
index 00000000..845b05ce
--- /dev/null
+++ b/pkg/lib/util/net.go
@@ -0,0 +1,23 @@
+package util
+
+import (
+ "net"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+// WaitForTCP polls indefinitely until it can connect to the given TCP address.
+func WaitForTCP(addr string) {
+ logrus.Infof("Waiting for TCP address %q to become connectable...", addr)
+
+ for {
+ c, err := net.Dial("tcp", addr)
+ if err == nil {
+ c.Close()
+ break
+ }
+
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/smtp_server.go b/smtp_server.go
new file mode 100644
index 00000000..bfb36aa2
--- /dev/null
+++ b/smtp_server.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "context"
+
+ esmtp "github.com/emersion/go-smtp"
+ "github.com/pkg/errors"
+ "github.com/sirupsen/logrus"
+
+ "github.com/programmfabrik/apitest/internal/smtp"
+ "github.com/programmfabrik/apitest/pkg/lib/util"
+)
+
+// StartSmtpServer starts the testing SMTP server, if configured.
+func (ats *Suite) StartSmtpServer() {
+ if ats.SmtpServer == nil || ats.smtpServer != nil {
+ return
+ }
+
+ ats.smtpServer = smtp.NewServer(ats.SmtpServer.Addr, ats.SmtpServer.MaxMessageSize)
+
+ go func() {
+ if !ats.Config.LogShort {
+ logrus.Infof("Starting SMTP Server: %s", ats.SmtpServer.Addr)
+ }
+
+ err := ats.smtpServer.ListenAndServe()
+ if err != nil && !errors.Is(err, esmtp.ErrServerClosed) {
+ // Error starting or closing listener:
+ logrus.Fatal("SMTP server ListenAndServe:", err)
+ }
+ }()
+
+ util.WaitForTCP(ats.SmtpServer.Addr)
+}
+
+// StopSmtpServer stops the SMTP server that was started using StartSMTPServer.
+func (ats *Suite) StopSmtpServer() {
+ if ats.SmtpServer == nil || ats.smtpServer == nil {
+ return
+ }
+
+ // TODO: Shouldn't this use a context with a timeout (also at http_server.go)?
+ err := ats.smtpServer.Shutdown(context.Background())
+ if err != nil {
+ // logrus.Error is used instead of Fatal, because an error
+ // during closing of a server shouldn't affect the outcome of
+ // the test.
+ logrus.Error("SMTP Server shutdown:", err)
+ } else if !ats.Config.LogShort {
+ logrus.Info("SMTP Server stopped")
+ }
+
+ ats.smtpServer = nil
+}
diff --git a/test/email/checkbody.json b/test/email/checkbody.json
new file mode 100644
index 00000000..fba851c4
--- /dev/null
+++ b/test/email/checkbody.json
@@ -0,0 +1,104 @@
+[
+ {
+ "name": "raw message data is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/raw",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "0965d04dceb3003d88a9ea38f337e97a"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ },
+ {
+ "name": "top-level body is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/body",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "c182b42fda0cd2e929272dd328f0c45d"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ },
+ {
+ "name": "part 0 body is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/0/body",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "7883ef0e6c2f3da210febb5ca2b42ace"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ },
+ {
+ "name": "part 1 body is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1/body",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "effe7461f030f3a58f85e4b736bd5c10"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ },
+ {
+ "name": "part 1/0 body is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1/multipart/0/body",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "81c8d7e43a1c88daf9031518e8db3136"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ },
+ {
+ "name": "part 1/1 body is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1/multipart/1/body",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {
+ "md5sum": "47ebf0f785a0788262472bf73a4801bb"
+ },
+ "format": {
+ "type": "binary"
+ }
+ }
+ }
+]
diff --git a/test/email/checkmetadata.json b/test/email/checkmetadata.json
new file mode 100644
index 00000000..b474da4e
--- /dev/null
+++ b/test/email/checkmetadata.json
@@ -0,0 +1,75 @@
+{{ $decodedMetadata := file "expected_metadata.json" | unmarshal }}
+[
+ {
+ "name": "top-level index is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ file "expected_index.json" }}
+ }
+ },
+ {
+ "name": "top-level metadata is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ marshal $decodedMetadata }}
+ }
+ },
+ {
+ "name": "part 0 metadata is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/0",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ index $decodedMetadata "multiparts" 0 | marshal }}
+ }
+ },
+ {
+ "name": "part 1 metadata is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ index $decodedMetadata "multiparts" 1 | marshal }}
+ }
+ },
+ {
+ "name": "part 1/0 metadata is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1/multipart/0",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ index $decodedMetadata "multiparts" 1 "multiparts" 0 | marshal }}
+ }
+ },
+ {
+ "name": "part 1/1 metadata is returned as expected",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/0/multipart/1/multipart/1",
+ "method": "GET"
+ },
+ "response": {
+ "statuscode": 200,
+ "body": {{ index $decodedMetadata "multiparts" 1 "multiparts" 1 | marshal }}
+ }
+ }
+]
diff --git a/test/email/expected_index.json b/test/email/expected_index.json
new file mode 100644
index 00000000..66edbdf9
--- /dev/null
+++ b/test/email/expected_index.json
@@ -0,0 +1,23 @@
+{
+ "count": 1,
+ "messages": [
+ {
+ "from": [
+ "testsender123@programmfabrik.de"
+ ],
+ "idx": 0,
+ "isMultipart": true,
+ "receivedAt:control": {
+ "match": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+(((\\+|\\-)[0-9]{2}:[0-9]{2})|Z)"
+ },
+ "smtpFrom": "testsender123@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver123@programmfabrik.de"
+ ],
+ "subject": "Example Nested Message",
+ "to": [
+ "testreceiver123@programmfabrik.de"
+ ]
+ }
+ ]
+}
diff --git a/test/email/expected_metadata.json b/test/email/expected_metadata.json
new file mode 100644
index 00000000..9db48982
--- /dev/null
+++ b/test/email/expected_metadata.json
@@ -0,0 +1,102 @@
+{
+ "bodySize": 549,
+ "contentType": "multipart/alternative",
+ "contentTypeParams": {
+ "boundary": "d36c3118be4745f9a1cb4556d11fe92d"
+ },
+ "from": [
+ "testsender123@programmfabrik.de"
+ ],
+ "headers": {
+ "Content-Type": [
+ "multipart/alternative; boundary=\"d36c3118be4745f9a1cb4556d11fe92d\""
+ ],
+ "Date": [
+ "Tue, 25 Jun 2024 11:15:57 +0200"
+ ],
+ "From": [
+ "testsender123@programmfabrik.de"
+ ],
+ "Subject": [
+ "Example Nested Message"
+ ],
+ "To": [
+ "testreceiver123@programmfabrik.de"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": true,
+ "multiparts": [
+ {
+ "bodySize": 57,
+ "contentType": "text/plain",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain; charset=utf-8"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": false
+ },
+ {
+ "bodySize": 257,
+ "contentType": "multipart/mixed",
+ "contentTypeParams": {
+ "boundary": "710d3e95c17247d4bb35d621f25e094e"
+ },
+ "headers": {
+ "Content-Type": [
+ "multipart/mixed; boundary=\"710d3e95c17247d4bb35d621f25e094e\""
+ ]
+ },
+ "idx": 1,
+ "isMultipart": true,
+ "multiparts": [
+ {
+ "bodySize": 26,
+ "contentType": "text/plain",
+ "contentTypeParams": {
+ "charset": "ascii"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain; charset=ascii"
+ ]
+ },
+ "idx": 0,
+ "isMultipart": false
+ },
+ {
+ "bodySize": 34,
+ "contentType": "text/html",
+ "contentTypeParams": {
+ "charset": "utf-8"
+ },
+ "headers": {
+ "Content-Type": [
+ "text/html; charset=utf-8"
+ ]
+ },
+ "idx": 1,
+ "isMultipart": false
+ }
+ ],
+ "multipartsCount": 2
+ }
+ ],
+ "multipartsCount": 2,
+ "receivedAt:control": {
+ "match": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+(((\\+|\\-)[0-9]{2}:[0-9]{2})|Z)"
+ },
+ "smtpFrom": "testsender123@programmfabrik.de",
+ "smtpRcptTo": [
+ "testreceiver123@programmfabrik.de"
+ ],
+ "subject": "Example Nested Message",
+ "to": [
+ "testreceiver123@programmfabrik.de"
+ ]
+}
diff --git a/test/email/manifest.json b/test/email/manifest.json
new file mode 100644
index 00000000..7ceeef0f
--- /dev/null
+++ b/test/email/manifest.json
@@ -0,0 +1,14 @@
+{
+ "http_server": {
+ "addr": ":9999"
+ },
+ "smtp_server": {
+ "addr": ":9925"
+ },
+ "name": "testing HTTP endpoints for mock SMTP server",
+ "tests": [
+ "@postmessage.json",
+ "@checkmetadata.json",
+ "@checkbody.json"
+ ]
+}
diff --git a/test/email/postmessage.json b/test/email/postmessage.json
new file mode 100644
index 00000000..466f5d07
--- /dev/null
+++ b/test/email/postmessage.json
@@ -0,0 +1,13 @@
+{
+ "name": "post message",
+ "request": {
+ "server_url": "http://localhost:9999",
+ "endpoint": "smtp/postmessage",
+ "method": "POST",
+ "body_type": "file",
+ "body_file": "testmessage.eml"
+ },
+ "response": {
+ "statuscode": 200
+ }
+}
diff --git a/test/email/testmessage.eml b/test/email/testmessage.eml
new file mode 100644
index 00000000..a2f6f3d2
--- /dev/null
+++ b/test/email/testmessage.eml
@@ -0,0 +1,23 @@
+From: testsender123@programmfabrik.de
+To: testreceiver123@programmfabrik.de
+Date: Tue, 25 Jun 2024 11:15:57 +0200
+Subject: Example Nested Message
+Content-type: multipart/alternative; boundary="d36c3118be4745f9a1cb4556d11fe92d"
+
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: text/plain; charset=utf-8
+
+Some plain text for clients that don't support multipart.
+--d36c3118be4745f9a1cb4556d11fe92d
+Content-Type: multipart/mixed; boundary="710d3e95c17247d4bb35d621f25e094e"
+
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/plain; charset=ascii
+
+This is the first subpart.
+--710d3e95c17247d4bb35d621f25e094e
+Content-Type: text/html; charset=utf-8
+
+This is the second subpart.
+--710d3e95c17247d4bb35d621f25e094e--
+--d36c3118be4745f9a1cb4556d11fe92d--