From 3c4b740849d1988e92eff2755fcae0dae9e7be1e Mon Sep 17 00:00:00 2001 From: rmanach Date: Sun, 10 Sep 2023 21:59:57 +0200 Subject: [PATCH] split watcher and http server from sender + add golanci config --- .golangci.yml | 134 ++++++++++++++++++++++++++++++++++++++++ config/smtp.go | 8 +-- mail/mail.go | 27 +++++--- mail/mail_test.go | 1 - main.go | 12 ++-- services/sender.go | 146 ++++++++++++-------------------------------- services/server.go | 99 ++++++++++++++++++++++++++++++ services/watcher.go | 100 ++++++++++++++++++++++++++++++ 8 files changed, 401 insertions(+), 126 deletions(-) create mode 100644 .golangci.yml create mode 100644 services/server.go create mode 100644 services/watcher.go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b9be002 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,134 @@ +linters-settings: + # depguard: // Specific for golangci repository + # list-type: blacklist + # packages: + # # logging is allowed only by logutils.Log, logrus + # # is allowed to use only in logutils package + # - github.com/sirupsen/logrus + # packages-with-error-message: + # - github.com/sirupsen/logrus: 'logging is allowed only by logutils.Log' + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + gci: + local-prefixes: localenv + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + # - whyNoLint + - wrapperFunc + gocyclo: + min-complexity: 15 + goimports: + local-prefixes: localenv + gomnd: + settings: + mnd: + # don't include the "operation" and "assign" + checks: + - argument + - case + - condition + - return + govet: + check-shadowing: true + # settings: // Specific for golangci repository + # printf: + # funcs: + # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof + # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf + # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf + # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf + lll: + line-length: 200 + maligned: + suggest-new: true + misspell: + locale: US + nolintlint: + allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space) + allow-unused: false # report any unused nolint directives + require-explanation: false # don't require an explanation for nolint directives + require-specific: false # don't require nolint directives to be specific about which linter is being skipped + errcheck: + check-blank: true + exclude-functions: + - '(*github.com/gin-gonic/gin.Error).SetType' + - '(*github.com/gin-gonic/gin.Context).Error' + +linters: + disable-all: true + enable: + - bodyclose + # - deadcode # deprecated (since v1.49.0) + # - depguard + - dogsled + - dupl + - errcheck + - exportloopref + - exhaustive + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - gofmt + - goimports + - gomnd + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - noctx + - nolintlint + # - rowserrcheck # https://github.com/golangci/golangci-lint/issues/2649 + - staticcheck + # - structcheck # https://github.com/golangci/golangci-lint/issues/2649 + - stylecheck + - typecheck + - unconvert + - unparam + - unused + # - varcheck # deprecated (since v1.49.0) + - whitespace + # - gochecknoglobals # too many global in ds9 + + # don't enable: + # - asciicheck + # - scopelint + # - gocognit + # - godot + # - godox + # - goerr113 + # - interfacer + # - maligned + # - nestif + # - prealloc + # - testpackage + # - revive + # - wsl + +# issues: +# Excluding configuration per-path, per-linter, per-text and per-source +# fix: true + +run: + timeout: 5m + skip-dirs: [] \ No newline at end of file diff --git a/config/smtp.go b/config/smtp.go index d53de06..2b91bc9 100644 --- a/config/smtp.go +++ b/config/smtp.go @@ -10,7 +10,7 @@ import ( type SMTPConfig struct { User string `validate:"required"` Password string `validate:"required"` - Url string `validate:"required"` + URL string `validate:"required"` Port string `validate:"required"` } @@ -18,7 +18,7 @@ func NewSMTPConfig(user, password, url, port string) (SMTPConfig, error) { config := SMTPConfig{ User: user, Password: password, - Url: url, + URL: url, Port: port, } @@ -30,6 +30,6 @@ func NewSMTPConfig(user, password, url, port string) (SMTPConfig, error) { return config, nil } -func (c SMTPConfig) GetFullUrl() string { - return fmt.Sprintf("%s:%s", c.Url, c.Port) +func (c SMTPConfig) GetFullURL() string { + return fmt.Sprintf("%s:%s", c.URL, c.Port) } diff --git a/mail/mail.go b/mail/mail.go index 49a9114..c02f177 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -20,30 +20,29 @@ type Email struct { } func FromJSON(path string) (Email, error) { - var mail Email + var email Email content, err := os.ReadFile(path) if err != nil { - return mail, fmt.Errorf("%w, unable to read the file: %s", err, path) + return email, fmt.Errorf("%w, unable to read the file: %s", err, path) } - if err := json.Unmarshal(content, &mail); err != nil { - return mail, err + if err := json.Unmarshal(content, &email); err != nil { + return email, err } - validate = validator.New(validator.WithRequiredStructEnabled()) - if err := validate.Struct(mail); err != nil { - return mail, err + if err := email.Validate(); err != nil { + return email, err } - return mail, nil + return email, nil } -func (e Email) GetReceivers() []string { +func (e *Email) GetReceivers() []string { return strings.Split(e.Receivers, ",") } -func (e Email) Generate() []byte { +func (e *Email) Generate() []byte { mail := fmt.Sprintf( "To: %s\nFrom: %s\nContent-Type: text/html;charset=utf-8\nSubject: %s\n\n%s", e.Receivers, @@ -53,3 +52,11 @@ func (e Email) Generate() []byte { ) return []byte(mail) } + +func (e *Email) Validate() error { + validate = validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(e); err != nil { + return err + } + return nil +} diff --git a/mail/mail_test.go b/mail/mail_test.go index 2a2a777..cd071aa 100644 --- a/mail/mail_test.go +++ b/mail/mail_test.go @@ -56,5 +56,4 @@ func TestFromJson(t *testing.T) { assert.Contains(t, err.Error(), "validation for 'Sender'") }) - } diff --git a/main.go b/main.go index 9de765d..ece9ce3 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "io/fs" "os" cfg "mailsrv/config" @@ -12,17 +13,20 @@ import ( ini "gopkg.in/ini.v1" ) -const DefaultOutboxPath string = "outbox" +const ( + DefaultOutboxPath string = "outbox" + DefaultPermissions fs.FileMode = 0750 +) var iniPath = kingpin.Arg("ini", ".ini file path").Required().String() func LoadIni(iniPath string) (*ini.File, error) { - ini, err := ini.Load(iniPath) + iniFile, err := ini.Load(iniPath) if err != nil { return nil, err } - return ini, nil + return iniFile, nil } // LoadSMTPConfig collects mandatory SMTP parameters to send an e-mail from the `.ini` file @@ -51,7 +55,7 @@ func GetOutboxPath(iniFile *ini.File) (string, error) { outboxPath = DefaultOutboxPath } - if err := os.MkdirAll(outboxPath, 0750); err != nil && !os.IsExist(err) { + if err := os.MkdirAll(outboxPath, DefaultPermissions); err != nil && !os.IsExist(err) { return "", err } diff --git a/services/sender.go b/services/sender.go index 76b7b08..e1c0d82 100644 --- a/services/sender.go +++ b/services/sender.go @@ -1,51 +1,39 @@ package services import ( - "encoding/json" - "fmt" - "io" + "context" cfg "mailsrv/config" "mailsrv/mail" "mailsrv/runtime" "os" "os/signal" - "path" - "strings" "syscall" - "time" - "net/http" "net/smtp" "github.com/rs/zerolog/log" ) -const ( - TickerInterval time.Duration = 10 * time.Second - JSONSuffix string = ".json" - ErrorSuffix string = ".err" -) - type Sender struct { - smtpConfig cfg.SMTPConfig - // fetch this directory to collect `.json` e-mail format + auth smtp.Auth + smtpURL string + outboxPath string - queue *runtime.Queue + + queue *runtime.Queue } func NewSender(config cfg.SMTPConfig, outboxPath string) Sender { return Sender{ - smtpConfig: config, + auth: smtp.PlainAuth("", config.User, config.Password, config.URL), + smtpURL: config.GetFullURL(), outboxPath: outboxPath, queue: runtime.NewQueue(), } } -func (s Sender) SendMail(email mail.Email) error { - auth := smtp.PlainAuth("", s.smtpConfig.User, s.smtpConfig.Password, s.smtpConfig.Url) - log.Debug().Msg("SMTP authentication succeed") - - if err := smtp.SendMail(s.smtpConfig.GetFullUrl(), auth, email.Sender, email.GetReceivers(), email.Generate()); err != nil { +func (s Sender) SendMail(email *mail.Email) error { + if err := smtp.SendMail(s.smtpURL, s.auth, email.Sender, email.GetReceivers(), email.Generate()); err != nil { return err } @@ -53,80 +41,6 @@ func (s Sender) SendMail(email mail.Email) error { return nil } -func (s Sender) mailHandler(w http.ResponseWriter, r *http.Request) { - content, err := io.ReadAll(r.Body) - if err != nil { - log.Err(err).Msg("unable to read request body") - w.WriteHeader(http.StatusInternalServerError) - return - } - - var mail mail.Email - if err := json.Unmarshal(content, &mail); err != nil { - log.Err(err).Msg("unable to deserialized request body into mail") - w.WriteHeader(http.StatusInternalServerError) - return - } - - s.queue.Add(mail) - w.WriteHeader(http.StatusOK) -} - -func (s Sender) runHTTPserver() { - - mux := http.NewServeMux() - mux.HandleFunc("/mail", s.mailHandler) - - if err := http.ListenAndServe(":1212", mux); err != nil { - log.Err(err).Msg("http server stops listening") - } -} - -// watchOutbox reads the `outbox` directory every `TickInterval` and put JSON format e-mail in the queue. -func (s Sender) watchOutbox() { - log.Info().Str("outbox", s.outboxPath).Msg("start watching outbox directory") - - ticker := time.NewTicker(TickerInterval) - - go func() { - for range ticker.C { - log.Debug().Str("action", "retrieving json e-mail format...").Str("path", s.outboxPath) - - files, err := os.ReadDir(s.outboxPath) - if err != nil && !os.IsExist(err) { - log.Err(err).Msg("outbox directory does not exist") - s.queue.Shutdown() - } - - for _, file := range files { - filename := file.Name() - if !strings.HasSuffix(filename, JSONSuffix) { - log.Debug().Str("filename", filename).Msg("incorrect suffix") - continue - } - - path := path.Join(s.outboxPath, filename) - email, err := mail.FromJSON(path) - - if err != nil { - log.Err(err).Str("path", path).Msg("unable to parse JSON email") - - // if JSON parsing failed the `path` is renamed with an error suffix to not watch it again - newPath := fmt.Sprintf("%s%s", path, ErrorSuffix) - if err := os.Rename(path, newPath); err != nil { - log.Err(err).Str("path", path).Str("new path", newPath).Msg("unable to rename bad JSON email path") - } - - continue - } - - email.Path = path - s.queue.Add(email) - } - } - }() -} - // processNextEmail iterates over the queue and send email. func (s Sender) processNextEmail() bool { item, quit := s.queue.Get() @@ -141,7 +55,7 @@ func (s Sender) processNextEmail() bool { return true } - if err := s.SendMail(email); err != nil { + if err := s.SendMail(&email); err != nil { log.Err(err).Msg("unable to send the email") } @@ -169,26 +83,44 @@ func (s Sender) run() <-chan struct{} { return chQueue } -// Run launches the queue processing and the outbox watcher. -// It catches `SIGINT` and `SIGTERM` to properly stopped the queue. +// Run launches the queue processing, the outbox watcher and the HTTP server. +// It catches `SIGINT` and `SIGTERM` to properly stopped the queue and the services. func (s Sender) Run() { - log.Info().Msg("sender service is running") + ctx, fnCancel := context.WithCancel(context.Background()) chSignal := make(chan os.Signal, 1) signal.Notify(chSignal, os.Interrupt, syscall.SIGTERM) - s.watchOutbox() chQueue := s.run() - go s.runHTTPserver() + server := NewServer(ctx, "1212", s.queue) + server.Serve() + + watcher := NewDirectoryWatch(ctx, s.outboxPath, s.queue) + watcher.Watch() + + log.Info().Msg("sender service is running...") select { case <-chSignal: - log.Warn().Msg("stop signal received, stopping e-mail queue...") - s.queue.Shutdown() - case <-chQueue: - log.Info().Msg("e-mail queue stopped successfully") + log.Warn().Msg("stop signal received, stopping...") + fnCancel() + case <-watcher.Done(): + log.Warn().Msg("watcher is done, stopping...") + fnCancel() + case <-server.Done(): + log.Warn().Msg("server is done, stopping...") + fnCancel() } - log.Info().Msg("sender service stopped successfully") + <-server.Done() + log.Info().Msg("http server stopped successfully") + + <-watcher.Done() + log.Info().Msg("watcher stopped successfully") + + s.queue.Shutdown() + <-chQueue + + log.Info().Msg("mailsrv stopped gracefully") } diff --git a/services/server.go b/services/server.go new file mode 100644 index 0000000..f2b76ed --- /dev/null +++ b/services/server.go @@ -0,0 +1,99 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mailsrv/mail" + "mailsrv/runtime" + "net/http" + "time" + + "github.com/rs/zerolog/log" +) + +type HTTPServer interface { + Serve() + Done() <-chan struct{} +} + +type Server struct { + ctx context.Context + fnCancel context.CancelFunc + + port string + queue *runtime.Queue + + chDone chan struct{} +} + +func NewServer(ctx context.Context, port string, queue *runtime.Queue) Server { + ctxChild, fnCancel := context.WithCancel(ctx) + + return Server{ + ctx: ctxChild, + fnCancel: fnCancel, + port: port, + queue: queue, + chDone: make(chan struct{}), + } +} + +func (s Server) Done() <-chan struct{} { + return s.chDone +} + +func (s *Server) Serve() { + mux := http.NewServeMux() + mux.HandleFunc("/mail", s.handler) + + log.Info().Str("port", s.port).Msg("http server is listening...") + + server := &http.Server{ + Addr: fmt.Sprintf(":%s", s.port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { + <-s.ctx.Done() + if err := server.Shutdown(s.ctx); err != nil { + log.Err(err).Msg("bad server shutdown") + } + s.chDone <- struct{}{} + }() + + go func() { + if err := server.ListenAndServe(); err != nil { + log.Err(err).Msg("http server stops listening") + s.fnCancel() + } + }() +} + +func (s *Server) handler(w http.ResponseWriter, r *http.Request) { + content, err := io.ReadAll(r.Body) + if err != nil { + log.Err(err).Msg("unable to read request body") + w.WriteHeader(http.StatusInternalServerError) + return + } + + var email mail.Email + if err := json.Unmarshal(content, &email); err != nil { + log.Err(err).Msg("unable to deserialized request body into mail") + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := email.Validate(); err != nil { + log.Err(err).Msg("email validation failed") + w.WriteHeader(http.StatusBadRequest) + return + } + + s.queue.Add(email) + w.WriteHeader(http.StatusOK) +} diff --git a/services/watcher.go b/services/watcher.go new file mode 100644 index 0000000..13d15a7 --- /dev/null +++ b/services/watcher.go @@ -0,0 +1,100 @@ +package services + +import ( + "context" + "fmt" + "mailsrv/mail" + "mailsrv/runtime" + "os" + "path" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + TickerInterval time.Duration = 10 * time.Second + JSONSuffix string = ".json" + ErrorSuffix string = ".err" +) + +type Watcher interface { + Watch() + Done() <-chan struct{} +} + +// DirectoryWatch watches a directory every `tick` interval and collect email files. +type DirectoryWatch struct { + ctx context.Context + fnCancel context.CancelFunc + + outboxPath string + queue *runtime.Queue +} + +func NewDirectoryWatch(ctx context.Context, outboxPath string, queue *runtime.Queue) DirectoryWatch { + ctxChild, fnCancel := context.WithCancel(ctx) + + return DirectoryWatch{ + ctx: ctxChild, + fnCancel: fnCancel, + outboxPath: outboxPath, + queue: queue, + } +} + +func (dw DirectoryWatch) Done() <-chan struct{} { + return dw.ctx.Done() +} + +// Watch reads the `outbox` directory every `TickInterval` and put JSON format e-mail in the queue. +func (dw DirectoryWatch) Watch() { + log.Info().Str("outbox", dw.outboxPath).Msg("watching outbox directory...") + + ticker := time.NewTicker(TickerInterval) + + go func() { + for { + select { + case <-dw.Done(): + log.Err(dw.ctx.Err()).Msg("context done") + return + case <-ticker.C: + log.Debug().Str("action", "retrieving json e-mail format...").Str("path", dw.outboxPath) + + files, err := os.ReadDir(dw.outboxPath) + if err != nil && !os.IsExist(err) { + log.Err(err).Msg("outbox directory does not exist") + dw.fnCancel() + return + } + + for _, file := range files { + filename := file.Name() + if !strings.HasSuffix(filename, JSONSuffix) { + continue + } + + emailPath := path.Join(dw.outboxPath, filename) + email, err := mail.FromJSON(emailPath) + + if err != nil { + log.Err(err).Str("path", emailPath).Msg("unable to parse JSON email") + + // if JSON parsing failed the `path` is renamed with an error suffix to not watch it again + newPath := fmt.Sprintf("%s%s", emailPath, ErrorSuffix) + if err := os.Rename(emailPath, newPath); err != nil { + log.Err(err).Str("path", emailPath).Str("new path", newPath).Msg("unable to rename bad JSON email path") + } + + continue + } + + email.Path = emailPath + dw.queue.Add(email) + } + } + } + }() +}