Skip to content

Commit

Permalink
Basic structure for SMTP server
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas Hinderberger committed Jun 21, 2024
1 parent 14c21af commit 21e67d0
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 3 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2575,3 +2575,6 @@ The expected response:
}
}
```
## SMTP Server
TODO: Add section about SMTP Server
9 changes: 9 additions & 0 deletions api_testsuite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
16 changes: 13 additions & 3 deletions http_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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,
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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:
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/smtp/http.go
Original file line number Diff line number Diff line change
@@ -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")
}
15 changes: 15 additions & 0 deletions internal/smtp/message.go
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions internal/smtp/server.go
Original file line number Diff line number Diff line change
@@ -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")
}
47 changes: 47 additions & 0 deletions smtp_server.go
Original file line number Diff line number Diff line change
@@ -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")
}
}

0 comments on commit 21e67d0

Please sign in to comment.