Compare commits
	
		
			1 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b94d7075fa | 
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1 @@ | ||||
| builds | ||||
| outbox | ||||
| 
 | ||||
| *.ini | ||||
| mailsrv | ||||
|  | ||||
							
								
								
									
										134
									
								
								.golangci.yml
									
									
									
									
									
								
							
							
						
						
									
										134
									
								
								.golangci.yml
									
									
									
									
									
								
							| @ -1,134 +0,0 @@ | ||||
| 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: [] | ||||
							
								
								
									
										32
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,20 +1,26 @@ | ||||
| .DEFAULT_GOAL := build | ||||
| 
 | ||||
| .DEFAULT_GOAL := run | ||||
| 
 | ||||
| BIN_NAME := mailsrv | ||||
| ROOT_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST)))) | ||||
| BUILD_DIR := $(ROOT_DIR)builds | ||||
| 
 | ||||
| build: format lint | ||||
| 	@mkdir -p $(BUILD_DIR) | ||||
| 	@go build -o $(BUILD_DIR)/$(BIN_NAME) main.go | ||||
| 
 | ||||
| format: | ||||
| fmt: | ||||
| 	go fmt ./... | ||||
| .PHONY:fmt | ||||
| 
 | ||||
| lint: | ||||
| 	golangci-lint run --fix | ||||
| lint: fmt | ||||
| 	golint ./... | ||||
| .PHONY:lint | ||||
| 
 | ||||
| vet: fmt | ||||
| 	go vet ./... | ||||
| 	shadow ./... | ||||
| .PHONY:vet | ||||
| 
 | ||||
| build: | ||||
| 	go build -o mailsrv | ||||
| .PHONY:build | ||||
| 
 | ||||
| build-check: vet | ||||
| 	go build -o mailsrv | ||||
| .PHONY:build | ||||
| 
 | ||||
| test: | ||||
| 	go test ./... | ||||
| .PHONY:test | ||||
|  | ||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							| @ -3,8 +3,9 @@ A little service to send e-mail. | ||||
| 
 | ||||
| ## Build | ||||
| ```bash | ||||
| make build | ||||
| make | ||||
| ``` | ||||
| `mailsrv` binary will be compiled. | ||||
| 
 | ||||
| ## Configuration | ||||
| `mailsrv` uses an SMTP server to send e-mail so, you need to provide some informations to send e-mails through it: | ||||
| @ -25,22 +26,15 @@ outbox_path = "<dir_path>" # directory used to retrieve `.json` e-mail format to | ||||
| ``` | ||||
| 
 | ||||
| ## How to send a mail ? | ||||
| ### JSON file | ||||
| * create a JSON file like: | ||||
| ```json | ||||
| { | ||||
|         "sender":"<email>", | ||||
|         "receivers": "<email_1>,<email_2>, ..., <email_n>", | ||||
|         "receivers": ["<email_1>","<email_2>", ..., "<email_n>"], | ||||
|         "subject": "<subject>", | ||||
|         "content": "<mail_content>" | ||||
| } | ||||
| ``` | ||||
| * put the JSON file in the `outbox` directory defined in the `.ini` file. | ||||
| * put the JSON file in the `outbox` directory define in the `.ini` file | ||||
| 
 | ||||
| ### HTTP | ||||
| An http server is embedded inside `mailsrv` and is listening on port **1212**. To send an email you can post a JSON to **/mail**: | ||||
| ``` | ||||
| curl http://localhost:1212/mail -d '{"sender":"test@test.com","receivers":"test1@test.com,test2@test.com","subject":"test","content":"<h1>test</h1>"}' | ||||
| ``` | ||||
| 
 | ||||
| **NOTE**: HTML is interpreted for the e-mail content. | ||||
| **NOTE**: HTML is interpreted for the e-mail content | ||||
|  | ||||
| @ -1,35 +1,43 @@ | ||||
| package config | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"github.com/go-playground/validator/v10" | ||||
| ) | ||||
| 
 | ||||
| // SMTPConfig handles mandatory parameters for the STMP server connection | ||||
| type SMTPConfig struct { | ||||
| 	User     string `validate:"required"` | ||||
| 	Password string `validate:"required"` | ||||
| 	URL      string `validate:"required"` | ||||
| 	Port     string `validate:"required"` | ||||
| 	User     string | ||||
| 	Password string | ||||
| 	Url      string | ||||
| 	Port     string | ||||
| } | ||||
| 
 | ||||
| func NewSMTPConfig(user, password, url, port string) (SMTPConfig, error) { | ||||
| 	config := SMTPConfig{ | ||||
| 		User:     user, | ||||
| 		Password: password, | ||||
| 		URL:      url, | ||||
| 		Port:     port, | ||||
| 	var config SMTPConfig | ||||
| 	if user == "" { | ||||
| 		return config, errors.New("SMTP user can't be empty") | ||||
| 	} | ||||
| 	config.User = user | ||||
| 
 | ||||
| 	validate := validator.New(validator.WithRequiredStructEnabled()) | ||||
| 	if err := validate.Struct(config); err != nil { | ||||
| 		return config, err | ||||
| 	if password == "" { | ||||
| 		return config, errors.New("SMTP password can't be empty") | ||||
| 	} | ||||
| 	config.Password = password | ||||
| 
 | ||||
| 	if url == "" { | ||||
| 		return config, errors.New("SMTP server url can't be empty") | ||||
| 	} | ||||
| 	config.Url = url | ||||
| 
 | ||||
| 	if port == "" { | ||||
| 		return config, errors.New("SMTP server port can't be empty") | ||||
| 	} | ||||
| 	config.Port = port | ||||
| 
 | ||||
| 	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) | ||||
| } | ||||
|  | ||||
| @ -1,26 +0,0 @@ | ||||
| package config | ||||
| 
 | ||||
| import ( | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/go-playground/validator/v10" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
| 
 | ||||
| func TestSMTPConfig(t *testing.T) { | ||||
| 	t.Run("right config", func(t *testing.T) { | ||||
| 		_, err := NewSMTPConfig("test", "test", "test", "test") | ||||
| 		require.NoError(t, err) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("empty user", func(t *testing.T) { | ||||
| 		_, err := NewSMTPConfig("", "test", "test", "test") | ||||
| 		require.Error(t, err) | ||||
| 
 | ||||
| 		_, ok := err.(*validator.InvalidValidationError) | ||||
| 		require.False(t, ok) | ||||
| 
 | ||||
| 		assert.Contains(t, err.Error(), "validation for 'User'") | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										23
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								go.mod
									
									
									
									
									
								
							| @ -3,27 +3,12 @@ module mailsrv | ||||
| go 1.21 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/alecthomas/kingpin/v2 v2.3.2 | ||||
| 	github.com/go-playground/validator/v10 v10.15.3 | ||||
| 	github.com/rs/zerolog v1.29.1 | ||||
| 	github.com/stretchr/testify v1.8.2 | ||||
| 	github.com/go-kit/kit v0.12.0 | ||||
| 	gopkg.in/ini.v1 v1.67.0 | ||||
| ) | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.2 // indirect | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/leodido/go-urn v1.2.4 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.12 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.14 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect | ||||
| 	golang.org/x/crypto v0.7.0 // indirect | ||||
| 	golang.org/x/net v0.8.0 // indirect | ||||
| 	golang.org/x/sys v0.6.0 // indirect | ||||
| 	golang.org/x/text v0.8.0 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| 	github.com/go-kit/log v0.2.0 // indirect | ||||
| 	github.com/go-logfmt/logfmt v0.5.1 // indirect | ||||
| 	github.com/stretchr/testify v1.8.0 // indirect | ||||
| ) | ||||
|  | ||||
							
								
								
									
										51
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										51
									
								
								go.sum
									
									
									
									
									
								
							| @ -1,59 +1,22 @@ | ||||
| github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= | ||||
| github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= | ||||
| github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= | ||||
| github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= | ||||
| github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | ||||
| 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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= | ||||
| github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||
| github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | ||||
| github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | ||||
| github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | ||||
| github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo= | ||||
| github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= | ||||
| github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||||
| github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= | ||||
| github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= | ||||
| github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= | ||||
| github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= | ||||
| github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= | ||||
| github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= | ||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= | ||||
| github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= | ||||
| github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw= | ||||
| github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= | ||||
| github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= | ||||
| github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= | ||||
| github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= | ||||
| github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||
| github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | ||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= | ||||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||||
| github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= | ||||
| github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||
| github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= | ||||
| github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= | ||||
| golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= | ||||
| golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= | ||||
| golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= | ||||
| golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= | ||||
| golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= | ||||
| golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= | ||||
| gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= | ||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
|  | ||||
							
								
								
									
										136
									
								
								mail/mail.go
									
									
									
									
									
								
							
							
						
						
									
										136
									
								
								mail/mail.go
									
									
									
									
									
								
							| @ -1,130 +1,50 @@ | ||||
| package mail | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"mime/multipart" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"io/ioutil" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/go-playground/validator/v10" | ||||
| ) | ||||
| 
 | ||||
| var validate *validator.Validate | ||||
| 
 | ||||
| // getgetAttachmentsContent collects attachment data from a list of attachment paths. | ||||
| func getAttachmentsContent(attachments []string) (map[string][]byte, error) { | ||||
| 	if len(attachments) == 0 { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 
 | ||||
| 	attachmentsData := map[string][]byte{} | ||||
| 
 | ||||
| 	for _, attachmentPath := range attachments { | ||||
| 		b, err := os.ReadFile(attachmentPath) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 
 | ||||
| 		_, filename := filepath.Split(attachmentPath) | ||||
| 		attachmentsData[filename] = b | ||||
| 	} | ||||
| 
 | ||||
| 	return attachmentsData, nil | ||||
| type Email struct { | ||||
| 	Sender    string   `json:"sender"` | ||||
| 	Receivers []string `json:"receivers"` | ||||
| 	Subject   string   `json:"subject"` | ||||
| 	Content   string   `json:"content"` | ||||
| } | ||||
| 
 | ||||
| type Email struct { | ||||
| 	Path        string | ||||
| 	Sender      string `json:"sender" validate:"required,email"` | ||||
| 	Receivers   string `json:"receivers" validate:"required"` | ||||
| 	Subject     string `json:"subject" validate:"required"` | ||||
| 	Content     string `json:"content" validate:"required"` | ||||
| 	Attachments string `json:"attachments,omitempty"` | ||||
| func NewEmail(sender string, receivers []string, subject, content string) Email { | ||||
| 	return Email{ | ||||
| 		Sender:    sender, | ||||
| 		Receivers: receivers, | ||||
| 		Subject:   subject, | ||||
| 		Content:   content, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func FromJSON(path string) (Email, error) { | ||||
| 	var email Email | ||||
| 	var mail Email | ||||
| 
 | ||||
| 	content, err := os.ReadFile(path) | ||||
| 	content, err := ioutil.ReadFile(path) | ||||
| 	if err != nil { | ||||
| 		return email, fmt.Errorf("%w, unable to read the file: %s", err, path) | ||||
| 		return mail, err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := json.Unmarshal(content, &email); err != nil { | ||||
| 		return email, err | ||||
| 	if err := json.Unmarshal(content, &mail); err != nil { | ||||
| 		return mail, err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := email.Validate(); err != nil { | ||||
| 		return email, err | ||||
| 	} | ||||
| 
 | ||||
| 	return email, nil | ||||
| 	return mail, nil | ||||
| } | ||||
| 
 | ||||
| func (e *Email) GetReceivers() []string { | ||||
| 	return strings.Split(e.Receivers, ",") | ||||
| } | ||||
| 
 | ||||
| func (e *Email) GetAttachments() []string { | ||||
| 	if e.Attachments == "" { | ||||
| 		return []string{} | ||||
| 	} | ||||
| 
 | ||||
| 	return strings.Split(e.Attachments, ",") | ||||
| } | ||||
| 
 | ||||
| func (e *Email) Generate() ([]byte, error) { | ||||
| 	buf := bytes.NewBuffer(nil) | ||||
| 	attachments := e.GetAttachments() | ||||
| 	withAttachments := len(attachments) != 0 | ||||
| 
 | ||||
| 	fmt.Fprintf(buf, "To: %s\n", strings.Join(e.GetReceivers(), ",")) | ||||
| 	fmt.Fprintf(buf, "From: %s\n", e.Sender) | ||||
| 	fmt.Fprintf(buf, "Subject: %s\n", e.Subject) | ||||
| 	buf.WriteString("MIME-Version: 1.0\n") | ||||
| 
 | ||||
| 	writer := multipart.NewWriter(buf) | ||||
| 	boundary := writer.Boundary() | ||||
| 	if withAttachments { | ||||
| 		fmt.Fprintf(buf, "Content-Type: multipart/mixed; boundary=%s\n\n", boundary) | ||||
| 		fmt.Fprintf(buf, "--%s\n", boundary) | ||||
| 	} | ||||
| 
 | ||||
| 	buf.WriteString("Content-Type: text/html; charset=utf-8\n\n") | ||||
| 	buf.WriteString(e.Content) | ||||
| 
 | ||||
| 	if withAttachments { | ||||
| 		attachmentsData, err := getAttachmentsContent(attachments) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 
 | ||||
| 		for filename, data := range attachmentsData { | ||||
| 			fmt.Fprintf(buf, "\n\n--%s\n", boundary) | ||||
| 			fmt.Fprintf(buf, "Content-Type: %s\n", http.DetectContentType(data)) | ||||
| 			buf.WriteString("Content-Transfer-Encoding: base64\n") | ||||
| 			fmt.Fprintf(buf, "Content-Disposition: attachment; filename=%s\n\n", filename) | ||||
| 
 | ||||
| 			b := make([]byte, base64.StdEncoding.EncodedLen(len(data))) | ||||
| 			base64.StdEncoding.Encode(b, data) | ||||
| 			buf.Write(b) | ||||
| 			fmt.Fprintf(buf, "\n\n--%s", boundary) | ||||
| 		} | ||||
| 
 | ||||
| 		buf.WriteString("--") | ||||
| 	} | ||||
| 
 | ||||
| 	return buf.Bytes(), nil | ||||
| } | ||||
| 
 | ||||
| func (e *Email) Validate() error { | ||||
| 	validate = validator.New(validator.WithRequiredStructEnabled()) | ||||
| 	if err := validate.Struct(e); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| func (e Email) Generate() []byte { | ||||
| 	mail := fmt.Sprintf( | ||||
| 		"To: %s\nFrom: %s\nContent-Type: text/html;charset=utf-8\nSubject: %s\n\n%s", | ||||
| 		strings.Join(e.Receivers, ","), | ||||
| 		e.Sender, | ||||
| 		e.Subject, | ||||
| 		e.Content, | ||||
| 	) | ||||
| 	return []byte(mail) | ||||
| } | ||||
|  | ||||
| @ -1,59 +0,0 @@ | ||||
| package mail | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
| 
 | ||||
| func TestFromJson(t *testing.T) { | ||||
| 	t.Run("right format", func(t *testing.T) { | ||||
| 		f, err := os.CreateTemp("", "temptest-") | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		defer f.Close() | ||||
| 		defer os.Remove(f.Name()) | ||||
| 
 | ||||
| 		data := []byte(`{ | ||||
| 			"sender":"test@test.com", | ||||
| 			"receivers": "test@test.com, test2@test.com", | ||||
| 			"subject": "test", | ||||
| 			"content": "test" | ||||
| 		}`) | ||||
| 
 | ||||
| 		n, err := f.Write(data) | ||||
| 		require.NoError(t, err) | ||||
| 		require.NotEmpty(t, n) | ||||
| 
 | ||||
| 		mail, err := FromJSON(f.Name()) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		assert.Equal(t, "test@test.com", mail.Sender) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("bad sender", func(t *testing.T) { | ||||
| 		f, err := os.CreateTemp("", "temptest-") | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		defer f.Close() | ||||
| 		defer os.Remove(f.Name()) | ||||
| 
 | ||||
| 		data := []byte(`{ | ||||
| 			"sender":"test", | ||||
| 			"receivers": "test@test.com, test2@test.com", | ||||
| 			"subject": "test", | ||||
| 			"content": "test" | ||||
| 		}`) | ||||
| 
 | ||||
| 		n, err := f.Write(data) | ||||
| 		require.NoError(t, err) | ||||
| 		require.NotEmpty(t, n) | ||||
| 
 | ||||
| 		_, err = FromJSON(f.Name()) | ||||
| 		require.Error(t, err) | ||||
| 
 | ||||
| 		assert.Contains(t, err.Error(), "validation for 'Sender'") | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										59
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										59
									
								
								main.go
									
									
									
									
									
								
							| @ -1,32 +1,46 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"io/fs" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 
 | ||||
| 	cfg "mailsrv/config" | ||||
| 	srv "mailsrv/services" | ||||
| 
 | ||||
| 	"github.com/alecthomas/kingpin/v2" | ||||
| 	"github.com/rs/zerolog" | ||||
| 	"github.com/rs/zerolog/log" | ||||
| 	"github.com/go-kit/kit/log" | ||||
| 	"github.com/go-kit/kit/log/level" | ||||
| 	ini "gopkg.in/ini.v1" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	DefaultOutboxPath  string      = "outbox" | ||||
| 	DefaultPermissions fs.FileMode = 0750 | ||||
| 	DefaultOutboxPath string = "outbox" | ||||
| ) | ||||
| 
 | ||||
| var iniPath = kingpin.Arg("ini", ".ini file path").Required().String() | ||||
| // GetConfigPath simply collects binary arguments | ||||
| func GetConfigPath() (string, error) { | ||||
| 	switch len(os.Args) { | ||||
| 	case 1: | ||||
| 		return "", errors.New("mailsrv must have .ini config file as first parameter") | ||||
| 	case 2: | ||||
| 		return os.Args[1], nil | ||||
| 	default: | ||||
| 		return "", errors.New("mailsrv must only have one parameter: .ini path file") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func LoadIni(iniPath string) (*ini.File, error) { | ||||
| 	iniFile, err := ini.Load(iniPath) | ||||
| func LoadIni() (*ini.File, error) { | ||||
| 	configPath, err := GetConfigPath() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("unable to get the .ini config path err=%v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return iniFile, nil | ||||
| 	ini, err := ini.Load(configPath) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("unable to load the .ini config path err=%v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return ini, nil | ||||
| } | ||||
| 
 | ||||
| // LoadSMTPConfig collects mandatory SMTP parameters to send an e-mail from the `.ini` file | ||||
| @ -41,7 +55,7 @@ func LoadSMTPConfig(iniFile *ini.File) (cfg.SMTPConfig, error) { | ||||
| 		section.Key("port").String(), | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return config, err | ||||
| 		return config, fmt.Errorf("failed to load the SMTP configuration err=%v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return config, nil | ||||
| @ -55,7 +69,7 @@ func GetOutboxPath(iniFile *ini.File) (string, error) { | ||||
| 		outboxPath = DefaultOutboxPath | ||||
| 	} | ||||
| 
 | ||||
| 	if err := os.MkdirAll(outboxPath, DefaultPermissions); err != nil && !os.IsExist(err) { | ||||
| 	if err := os.MkdirAll(outboxPath, 0750); err != nil && !os.IsExist(err) { | ||||
| 		return "", err | ||||
| 	} | ||||
| 
 | ||||
| @ -63,27 +77,28 @@ func GetOutboxPath(iniFile *ini.File) (string, error) { | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
| 	zerolog.TimeFieldFormat = zerolog.TimeFormatUnix | ||||
| 	log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) | ||||
| 	logger := log.NewLogfmtLogger(os.Stdout) | ||||
| 	logger = level.NewFilter(logger, level.AllowInfo()) | ||||
| 	logger = log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller, "service", "mailsrv") | ||||
| 
 | ||||
| 	kingpin.Parse() | ||||
| 
 | ||||
| 	iniFile, err := LoadIni(*iniPath) | ||||
| 	iniFile, err := LoadIni() | ||||
| 	if err != nil { | ||||
| 		log.Fatal().AnErr("err", err).Msg("unable to load the .ini configuration file") | ||||
| 		level.Error(logger).Log("msg", "unable to load the .ini configuration file", "err", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	config, err := LoadSMTPConfig(iniFile) | ||||
| 	if err != nil { | ||||
| 		log.Fatal().AnErr("err", err).Msg("unable to load the SMTP configuration") | ||||
| 		level.Error(logger).Log("msg", "unable to load the SMTP configuration", "err", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	outboxPath, err := GetOutboxPath(iniFile) | ||||
| 	if err != nil { | ||||
| 		log.Fatal().AnErr("err", err).Msg("unable to retrieve outputbox path") | ||||
| 		level.Error(logger).Log("msg", "unable to retrieve outputbox path", "err", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	sender := srv.NewSender(config, outboxPath) | ||||
| 	sender := srv.NewSender(logger, config, outboxPath) | ||||
| 	sender.Run() | ||||
| } | ||||
|  | ||||
| @ -1,52 +1,87 @@ | ||||
| package services | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	cfg "mailsrv/config" | ||||
| 	"mailsrv/mail" | ||||
| 	"mailsrv/runtime" | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"path" | ||||
| 	"strings" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/go-kit/kit/log" | ||||
| 	"github.com/go-kit/kit/log/level" | ||||
| 	"net/smtp" | ||||
| ) | ||||
| 
 | ||||
| 	"github.com/rs/zerolog/log" | ||||
| const ( | ||||
| 	TickerInterval time.Duration = 10 * time.Second | ||||
| 	JSONSuffix     string        = ".json" | ||||
| 	ErrorSuffix    string        = ".err" | ||||
| ) | ||||
| 
 | ||||
| type Sender struct { | ||||
| 	auth    smtp.Auth | ||||
| 	smtpURL string | ||||
| 
 | ||||
| 	smtpConfig cfg.SMTPConfig | ||||
| 	logger     log.Logger | ||||
| 	// fetch this directory to collect `.json` e-mail format | ||||
| 	outboxPath string | ||||
| 
 | ||||
| 	queue *runtime.Queue | ||||
| 	queue      *runtime.Queue | ||||
| } | ||||
| 
 | ||||
| func NewSender(config cfg.SMTPConfig, outboxPath string) Sender { | ||||
| func NewSender(logger log.Logger, config cfg.SMTPConfig, outboxPath string) Sender { | ||||
| 	logger = log.With(logger, "actor", "sender") | ||||
| 	return Sender{ | ||||
| 		auth:       smtp.PlainAuth("", config.User, config.Password, config.URL), | ||||
| 		smtpURL:    config.GetFullURL(), | ||||
| 		smtpConfig: config, | ||||
| 		logger:     logger, | ||||
| 		outboxPath: outboxPath, | ||||
| 		queue:      runtime.NewQueue(), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s Sender) SendMail(email *mail.Email) error { | ||||
| 	content, err := email.Generate() | ||||
| 	if err != nil { | ||||
| func (s Sender) SendMail(email mail.Email) error { | ||||
| 	auth := smtp.PlainAuth("", s.smtpConfig.User, s.smtpConfig.Password, s.smtpConfig.Url) | ||||
| 	level.Debug(s.logger).Log("msg", "SMTP authentication succeed") | ||||
| 
 | ||||
| 	if err := smtp.SendMail(s.smtpConfig.GetFullUrl(), auth, email.Sender, email.Receivers, email.Generate()); err != nil { | ||||
| 		level.Error(s.logger).Log("msg", "error while sending email", "err", err) | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := smtp.SendMail(s.smtpURL, s.auth, email.Sender, email.GetReceivers(), content); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	log.Debug().Msg("mail send successfully") | ||||
| 	level.Debug(s.logger).Log("msg", "mail send successfully") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // processNextEmail iterates over the queue and send email. | ||||
| // watchOutbox reads the `outbox` directory every `TickInterval` and put JSON format e-mail in the queue | ||||
| func (s Sender) watchOutbox() { | ||||
| 	s.logger.Log("msg", "start watching outbox directory", "outbox", s.outboxPath) | ||||
| 	ticker := time.NewTicker(TickerInterval) | ||||
| 
 | ||||
| 	go func() { | ||||
| 		for _ = range ticker.C { | ||||
| 			level.Debug(s.logger).Log("action", "retrieving json e-mail format...", "path", s.outboxPath) | ||||
| 
 | ||||
| 			files, err := os.ReadDir(s.outboxPath) | ||||
| 			if err != nil && !os.IsExist(err) { | ||||
| 				level.Error(s.logger).Log("msg", "outbox directory does not exist", "err", err) | ||||
| 				s.queue.Shutdown() | ||||
| 			} | ||||
| 
 | ||||
| 			for _, file := range files { | ||||
| 				filename := file.Name() | ||||
| 				if strings.HasSuffix(filename, JSONSuffix) { | ||||
| 					s.queue.Add(path.Join(s.outboxPath, filename)) | ||||
| 					continue | ||||
| 				} | ||||
| 				level.Debug(s.logger).Log("msg", "incorrect suffix", "filename", filename) | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| } | ||||
| 
 | ||||
| // processNextEmail loops over the queue and send email | ||||
| func (s Sender) processNextEmail() bool { | ||||
| 	item, quit := s.queue.Get() | ||||
| 	if quit { | ||||
| @ -54,78 +89,68 @@ func (s Sender) processNextEmail() bool { | ||||
| 	} | ||||
| 	defer s.queue.Done(item) | ||||
| 
 | ||||
| 	email, ok := item.(mail.Email) | ||||
| 	path, ok := item.(string) | ||||
| 	if !ok { | ||||
| 		log.Error().Any("item", item).Msg("unable to cast queue item into mail.Email") | ||||
| 		level.Error(s.logger).Log("msg", "unable to cast queue item into mail.Email", "item", item) | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	if err := s.SendMail(&email); err != nil { | ||||
| 		log.Err(err).Msg("unable to send the email") | ||||
| 	email, err := mail.FromJSON(path) | ||||
| 	if err != nil { | ||||
| 		level.Error(s.logger).Log("msg", "unable to parse JSON email", "path", path, "err", err) | ||||
| 		// if JSON parsing failed the `path` is renamed with an error suffix to avoid enqueued it again | ||||
| 		newPath := fmt.Sprintf("%s%s", path, ErrorSuffix) | ||||
| 		if err := os.Rename(path, newPath); err != nil { | ||||
| 			level.Error(s.logger).Log("msg", "unable to rename bad JSON email path", "path", path, "newPath", newPath) | ||||
| 			s.queue.Shutdown() | ||||
| 		} | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	if path := email.Path; path != "" { | ||||
| 		if err := os.Remove(path); err != nil { | ||||
| 			// this is a fatal error, can't send same e-mail indefinitely | ||||
| 			if !os.IsExist(err) { | ||||
| 				log.Err(err).Str("path", path).Msg("unable to remove the JSON email") | ||||
| 				s.queue.Shutdown() | ||||
| 			} | ||||
| 	// whatever the return, the email will be not enqueued again | ||||
| 	s.SendMail(email) | ||||
| 
 | ||||
| 	if err := os.Remove(path); err != nil { | ||||
| 		// this is a fatal error, can't send same e-mail indefinitely | ||||
| 		if !os.IsExist(err) { | ||||
| 			level.Error(s.logger).Log("msg", "unable to remove the JSON email", "path", path, "err", err) | ||||
| 			s.queue.Shutdown() | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| // run starts processing the queue. | ||||
| // run starts processing the queue | ||||
| func (s Sender) run() <-chan struct{} { | ||||
| 	chQueue := make(chan struct{}) | ||||
| 	queueCh := make(chan struct{}) | ||||
| 	go func() { | ||||
| 		for s.processNextEmail() { | ||||
| 		} | ||||
| 		chQueue <- struct{}{} | ||||
| 		queueCh <- struct{}{} | ||||
| 	}() | ||||
| 	return chQueue | ||||
| 	return queueCh | ||||
| } | ||||
| 
 | ||||
| // 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. | ||||
| // Run launches the queue processing and the outbox watcher | ||||
| // catches `SIGINT` and `SIGTERM` to properly stopped the queue | ||||
| func (s Sender) Run() { | ||||
| 	ctx, fnCancel := context.WithCancel(context.Background()) | ||||
| 	s.logger.Log("msg", "sender service is running") | ||||
| 
 | ||||
| 	chSignal := make(chan os.Signal, 1) | ||||
| 	signal.Notify(chSignal, os.Interrupt, syscall.SIGTERM) | ||||
| 	sigCh := make(chan os.Signal, 1) | ||||
| 	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | ||||
| 
 | ||||
| 	chQueue := s.run() | ||||
| 
 | ||||
| 	server := NewServer(ctx, "1212", s.queue) | ||||
| 	server.Serve() | ||||
| 
 | ||||
| 	watcher := NewDirectoryWatch(ctx, s.outboxPath, s.queue) | ||||
| 	watcher.Watch() | ||||
| 
 | ||||
| 	log.Info().Msg("sender service is running...") | ||||
| 	s.watchOutbox() | ||||
| 	queueCh := s.run() | ||||
| 
 | ||||
| 	select { | ||||
| 	case <-chSignal: | ||||
| 		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() | ||||
| 	case <-sigCh: | ||||
| 		s.logger.Log("msg", "stop signal received, stopping e-mail queue...") | ||||
| 		s.queue.Shutdown() | ||||
| 	case <-queueCh: | ||||
| 		s.logger.Log("msg", "e-mail queue 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") | ||||
| 	s.logger.Log("msg", "sender service stopped successfully") | ||||
| } | ||||
|  | ||||
| @ -1,99 +0,0 @@ | ||||
| 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) | ||||
| } | ||||
| @ -1,100 +0,0 @@ | ||||
| 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) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user