From 21e67d06a174ce480ea0eb99d7be25dbef0dfeaf Mon Sep 17 00:00:00 2001 From: Lucas Hinderberger Date: Fri, 21 Jun 2024 13:40:13 +0200 Subject: [PATCH] Basic structure for SMTP server --- README.md | 3 ++ api_testsuite.go | 9 ++++ go.mod | 2 + go.sum | 4 ++ http_server.go | 16 +++++-- internal/smtp/http.go | 10 +++++ internal/smtp/message.go | 15 +++++++ internal/smtp/server.go | 92 ++++++++++++++++++++++++++++++++++++++++ smtp_server.go | 47 ++++++++++++++++++++ 9 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 internal/smtp/http.go create mode 100644 internal/smtp/message.go create mode 100644 internal/smtp/server.go create mode 100644 smtp_server.go diff --git a/README.md b/README.md index 0ba4903a..37b3a863 100644 --- a/README.md +++ b/README.md @@ -2575,3 +2575,6 @@ The expected response: } } ``` + +## SMTP Server +TODO: Add section about SMTP Server diff --git a/api_testsuite.go b/api_testsuite.go index 5be3eb70..8e67650e 100644 --- a/api_testsuite.go +++ b/api_testsuite.go @@ -34,6 +34,9 @@ type Suite struct { Testmode bool `json:"testmode"` Proxy httpproxy.ProxyConfig `json:"proxy"` } `json:"http_server,omitempty"` + SmtpServer *struct { + Addr string `json:"addr"` + } Tests []any `json:"tests"` Store map[string]any `json:"store"` @@ -54,6 +57,7 @@ type Suite struct { idleConnsClosed chan struct{} HTTPServerHost string loader template.Loader + smtpServer *SmtpServer } // NewTestSuite creates a new suite on which we execute our tests on. Normally this only gets call from within the apitest main command @@ -175,10 +179,15 @@ func (ats *Suite) Run() bool { logrus.Infof("[%2d] '%s'", ats.index, ats.Name) } + ats.StartSmtpServer() + defer ats.StopSmtpServer() + ats.StartHttpServer() + // FIXME: Defer stop err := os.Chdir(ats.manifestDir) if err != nil { + // FIXME: This should be a fatal error - also check other occurrences of Error/Errorf logrus.Errorf("Unable to switch working directory to %q", ats.manifestDir) } 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..ffcd5c64 100644 --- a/http_server.go +++ b/http_server.go @@ -19,10 +19,12 @@ import ( // StartHttpServer start a simple http server that can server local test resources during the testsuite is running func (ats *Suite) StartHttpServer() { + // FIXME: This doesn't check if the http server is already initialized if ats.HttpServer == nil { return } + // TODO: Find out what idleConnsClosed does and if SMTP server needs it too ats.idleConnsClosed = make(chan struct{}) mux := http.NewServeMux() @@ -48,6 +50,11 @@ func (ats *Suite) StartHttpServer() { ats.httpServerProxy = httpproxy.New(ats.HttpServer.Proxy) ats.httpServerProxy.RegisterRoutes(mux, "/", ats.Config.LogShort) + // Register SMTP server query routes + if ats.smtpServer != nil { + ats.smtpServer.RegisterRoutes(mux, "/") + } + ats.httpServer = http.Server{ Addr: ats.HttpServer.Addr, Handler: mux, @@ -59,10 +66,11 @@ func (ats *Suite) StartHttpServer() { } err := ats.httpServer.ListenAndServe() - if err != http.ErrServerClosed { + if err != http.ErrServerClosed { // FIXME: Use errors.Is // Error starting or closing listener: + // FIXME: Use logrus.Fatal logrus.Errorf("HTTP server ListenAndServe: %v", err) - return + return // FIXME: This is redundant } } @@ -98,6 +106,8 @@ func (ats *Suite) StopHttpServer() { return } + // FIXME: There is no nil check for ats.httpServer; no protection against calling twice + err := ats.httpServer.Shutdown(context.Background()) if err != nil { // Error from closing listeners, or context timeout: @@ -107,7 +117,7 @@ func (ats *Suite) StopHttpServer() { } else if !ats.Config.LogShort { logrus.Infof("Http Server stopped: %s", ats.httpServerDir) } - return + return // FIXME: This is redundant } type ErrorResponse struct { diff --git a/internal/smtp/http.go b/internal/smtp/http.go new file mode 100644 index 00000000..519bfb11 --- /dev/null +++ b/internal/smtp/http.go @@ -0,0 +1,10 @@ +package smtp + +import "net/http" + +// RegisterRoutes sets up HTTP routes for inspecting the SMTP Server's +// received messages. +func (s *Server) RegisterRoutes(mux *http.ServeMux, prefix string) { + // TODO + panic("not implemented") +} diff --git a/internal/smtp/message.go b/internal/smtp/message.go new file mode 100644 index 00000000..ba1c7462 --- /dev/null +++ b/internal/smtp/message.go @@ -0,0 +1,15 @@ +package smtp + +import "time" + +// ReceivedMessage contains a single email message as received via SMTP. +type ReceivedMessage struct { + smtpFrom string + smtpRcptTo []string + rawMessageData []byte + receivedAt time.Time +} + +// TODO: Constructor that takes in from, rcptTo, rawMessageData, receivedAt and that also parses the message + +// TODO: Getters diff --git a/internal/smtp/server.go b/internal/smtp/server.go new file mode 100644 index 00000000..caedda59 --- /dev/null +++ b/internal/smtp/server.go @@ -0,0 +1,92 @@ +package smtp + +import ( + "fmt" + "regexp" + "sync" + + "github.com/emersion/go-smtp" +) + +// 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 + + mutex sync.RWMutex +} + +type session struct { + // TODO +} + +func NewServer(addr string) *Server { + server := &Server{ + receivedMessages: make([]receivedMessage), + } + + backend := panic("not implemented") // TODO + + server.server = smtp.NewServer(backend) + server.Addr = addr + // TODO: Enable SMTPUTF8? + // TODO: Enable BINARYMIME? + + return server +} + +// 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() error { + return s.server.Shutdown() +} + +// 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() + + msg, ok := s.receivedMessages[idx] + if !ok { + return nil, fmt.Errorf( + "Server does not contain message with index %d", idx, + ) + } + + return msg, 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.receivedMessage)) + copy(view, s.receivedMessages) + + return view +} + +// SearchByHeader returns the list of all received messages that have at +// least one header matching the given regular expression. +func (s *Server) SearchByHeader(re *regexp.Regexp) []ReceivedMessage { + s.mutex.RLock() + defer s.mutex.RUnlock() + + // TODO + panic("not implemented") +} diff --git a/smtp_server.go b/smtp_server.go new file mode 100644 index 00000000..e937b17e --- /dev/null +++ b/smtp_server.go @@ -0,0 +1,47 @@ +package main + +import ( + esmtp "github.com/emersion/go-smtp" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/programmfabrik/apitest/internal/smtp" +) + +// 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) + + go func() { + if !ats.Config.LogShort { + logrus.Infof("Starting SMTP Server: %s", ats.SmtpServer.Addr) + } + + err := ats.smtpServer.ListenAndServe() + if !errors.Is(err, esmtp.ErrServerClosed) { + // Error starting or closing listener: + logrus.Fatal("SMTP server ListenAndServe:", err) + } + }() +} + +// StopSmtpServer stops the SMTP server that was started using StartSMTPServer. +func (ats *Suite) StopSmtpServer() { + if ats.SmtpServer == nil || ats.smtpServer == nil { + return + } + + err := ats.smtpServer.Shutdown() + 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") + } +}