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 + + + + + + + + + + + + + + + + + + + +
IndexReceivedFromToSubjectDetails
+ + + + + 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 + + + + + + + +
+ + +
+
+

Message Metadata:

+
{{ .metadataJson }}
+
+
+

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--