split watcher and http server from sender + add golanci config

This commit is contained in:
rmanach 2023-09-10 21:59:57 +02:00
parent d831c3e0aa
commit 3c4b740849
8 changed files with 401 additions and 126 deletions

134
.golangci.yml Normal file
View File

@ -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: []

View File

@ -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)
}

View File

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

View File

@ -56,5 +56,4 @@ func TestFromJson(t *testing.T) {
assert.Contains(t, err.Error(), "validation for 'Sender'")
})
}

12
main.go
View File

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

View File

@ -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")
}

99
services/server.go Normal file
View File

@ -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)
}

100
services/watcher.go Normal file
View File

@ -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)
}
}
}
}()
}