Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b94d7075fa |
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,4 +1 @@
|
|||||||
builds
|
mailsrv
|
||||||
outbox
|
|
||||||
|
|
||||||
*.ini
|
|
||||||
|
|||||||
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 := build
|
||||||
|
|
||||||
.DEFAULT_GOAL := run
|
fmt:
|
||||||
|
|
||||||
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:
|
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
.PHONY:fmt
|
||||||
|
|
||||||
lint:
|
lint: fmt
|
||||||
golangci-lint run --fix
|
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:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
.PHONY:test
|
||||||
|
|||||||
16
README.md
16
README.md
@ -3,8 +3,9 @@ A little service to send e-mail.
|
|||||||
|
|
||||||
## Build
|
## Build
|
||||||
```bash
|
```bash
|
||||||
make build
|
make
|
||||||
```
|
```
|
||||||
|
`mailsrv` binary will be compiled.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
`mailsrv` uses an SMTP server to send e-mail so, you need to provide some informations to send e-mails through it:
|
`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 ?
|
## How to send a mail ?
|
||||||
### JSON file
|
|
||||||
* create a JSON file like:
|
* create a JSON file like:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sender":"<email>",
|
"sender":"<email>",
|
||||||
"receivers": "<email_1>,<email_2>, ..., <email_n>",
|
"receivers": ["<email_1>","<email_2>", ..., "<email_n>"],
|
||||||
"subject": "<subject>",
|
"subject": "<subject>",
|
||||||
"content": "<mail_content>"
|
"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
|
**NOTE**: HTML is interpreted for the e-mail content
|
||||||
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.
|
|
||||||
|
|||||||
@ -1,35 +1,43 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMTPConfig handles mandatory parameters for the STMP server connection
|
// SMTPConfig handles mandatory parameters for the STMP server connection
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
User string `validate:"required"`
|
User string
|
||||||
Password string `validate:"required"`
|
Password string
|
||||||
URL string `validate:"required"`
|
Url string
|
||||||
Port string `validate:"required"`
|
Port string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSMTPConfig(user, password, url, port string) (SMTPConfig, error) {
|
func NewSMTPConfig(user, password, url, port string) (SMTPConfig, error) {
|
||||||
config := SMTPConfig{
|
var config SMTPConfig
|
||||||
User: user,
|
if user == "" {
|
||||||
Password: password,
|
return config, errors.New("SMTP user can't be empty")
|
||||||
URL: url,
|
|
||||||
Port: port,
|
|
||||||
}
|
}
|
||||||
|
config.User = user
|
||||||
|
|
||||||
validate := validator.New(validator.WithRequiredStructEnabled())
|
if password == "" {
|
||||||
if err := validate.Struct(config); err != nil {
|
return config, errors.New("SMTP password can't be empty")
|
||||||
return config, err
|
|
||||||
}
|
}
|
||||||
|
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
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c SMTPConfig) GetFullURL() string {
|
func (c SMTPConfig) GetFullUrl() string {
|
||||||
return fmt.Sprintf("%s:%s", c.URL, c.Port)
|
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
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/kingpin/v2 v2.3.2
|
github.com/go-kit/kit v0.12.0
|
||||||
github.com/go-playground/validator/v10 v10.15.3
|
|
||||||
github.com/rs/zerolog v1.29.1
|
|
||||||
github.com/stretchr/testify v1.8.2
|
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
github.com/go-kit/log v0.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/go-logfmt/logfmt v0.5.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/stretchr/testify v1.8.0 // 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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.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.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.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.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/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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"io/ioutil"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var validate *validator.Validate
|
type Email struct {
|
||||||
|
Sender string `json:"sender"`
|
||||||
// getgetAttachmentsContent collects attachment data from a list of attachment paths.
|
Receivers []string `json:"receivers"`
|
||||||
func getAttachmentsContent(attachments []string) (map[string][]byte, error) {
|
Subject string `json:"subject"`
|
||||||
if len(attachments) == 0 {
|
Content string `json:"content"`
|
||||||
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 {
|
func NewEmail(sender string, receivers []string, subject, content string) Email {
|
||||||
Path string
|
return Email{
|
||||||
Sender string `json:"sender" validate:"required,email"`
|
Sender: sender,
|
||||||
Receivers string `json:"receivers" validate:"required"`
|
Receivers: receivers,
|
||||||
Subject string `json:"subject" validate:"required"`
|
Subject: subject,
|
||||||
Content string `json:"content" validate:"required"`
|
Content: content,
|
||||||
Attachments string `json:"attachments,omitempty"`
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromJSON(path string) (Email, error) {
|
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 {
|
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 {
|
if err := json.Unmarshal(content, &mail); err != nil {
|
||||||
return email, err
|
return mail, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := email.Validate(); err != nil {
|
return mail, nil
|
||||||
return email, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return email, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Email) GetReceivers() []string {
|
func (e Email) Generate() []byte {
|
||||||
return strings.Split(e.Receivers, ",")
|
mail := fmt.Sprintf(
|
||||||
}
|
"To: %s\nFrom: %s\nContent-Type: text/html;charset=utf-8\nSubject: %s\n\n%s",
|
||||||
|
strings.Join(e.Receivers, ","),
|
||||||
func (e *Email) GetAttachments() []string {
|
e.Sender,
|
||||||
if e.Attachments == "" {
|
e.Subject,
|
||||||
return []string{}
|
e.Content,
|
||||||
}
|
)
|
||||||
|
return []byte(mail)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
cfg "mailsrv/config"
|
cfg "mailsrv/config"
|
||||||
srv "mailsrv/services"
|
srv "mailsrv/services"
|
||||||
|
|
||||||
"github.com/alecthomas/kingpin/v2"
|
"github.com/go-kit/kit/log"
|
||||||
"github.com/rs/zerolog"
|
"github.com/go-kit/kit/log/level"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
ini "gopkg.in/ini.v1"
|
ini "gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultOutboxPath string = "outbox"
|
DefaultOutboxPath string = "outbox"
|
||||||
DefaultPermissions fs.FileMode = 0750
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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) {
|
func LoadIni() (*ini.File, error) {
|
||||||
iniFile, err := ini.Load(iniPath)
|
configPath, err := GetConfigPath()
|
||||||
if err != nil {
|
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
|
// 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(),
|
section.Key("port").String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, fmt.Errorf("failed to load the SMTP configuration err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
@ -55,7 +69,7 @@ func GetOutboxPath(iniFile *ini.File) (string, error) {
|
|||||||
outboxPath = DefaultOutboxPath
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,27 +77,28 @@ func GetOutboxPath(iniFile *ini.File) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
logger := log.NewLogfmtLogger(os.Stdout)
|
||||||
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
logger = level.NewFilter(logger, level.AllowInfo())
|
||||||
|
logger = log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller, "service", "mailsrv")
|
||||||
|
|
||||||
kingpin.Parse()
|
iniFile, err := LoadIni()
|
||||||
|
|
||||||
iniFile, err := LoadIni(*iniPath)
|
|
||||||
if err != nil {
|
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)
|
config, err := LoadSMTPConfig(iniFile)
|
||||||
if err != nil {
|
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)
|
outboxPath, err := GetOutboxPath(iniFile)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := srv.NewSender(config, outboxPath)
|
sender := srv.NewSender(logger, config, outboxPath)
|
||||||
sender.Run()
|
sender.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +1,87 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"fmt"
|
||||||
cfg "mailsrv/config"
|
cfg "mailsrv/config"
|
||||||
"mailsrv/mail"
|
"mailsrv/mail"
|
||||||
"mailsrv/runtime"
|
"mailsrv/runtime"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-kit/kit/log"
|
||||||
|
"github.com/go-kit/kit/log/level"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
const (
|
||||||
|
TickerInterval time.Duration = 10 * time.Second
|
||||||
|
JSONSuffix string = ".json"
|
||||||
|
ErrorSuffix string = ".err"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Sender struct {
|
type Sender struct {
|
||||||
auth smtp.Auth
|
smtpConfig cfg.SMTPConfig
|
||||||
smtpURL string
|
logger log.Logger
|
||||||
|
// fetch this directory to collect `.json` e-mail format
|
||||||
outboxPath string
|
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{
|
return Sender{
|
||||||
auth: smtp.PlainAuth("", config.User, config.Password, config.URL),
|
smtpConfig: config,
|
||||||
smtpURL: config.GetFullURL(),
|
logger: logger,
|
||||||
outboxPath: outboxPath,
|
outboxPath: outboxPath,
|
||||||
queue: runtime.NewQueue(),
|
queue: runtime.NewQueue(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Sender) SendMail(email *mail.Email) error {
|
func (s Sender) SendMail(email mail.Email) error {
|
||||||
content, err := email.Generate()
|
auth := smtp.PlainAuth("", s.smtpConfig.User, s.smtpConfig.Password, s.smtpConfig.Url)
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := smtp.SendMail(s.smtpURL, s.auth, email.Sender, email.GetReceivers(), content); err != nil {
|
level.Debug(s.logger).Log("msg", "mail send successfully")
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msg("mail send successfully")
|
|
||||||
return nil
|
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 {
|
func (s Sender) processNextEmail() bool {
|
||||||
item, quit := s.queue.Get()
|
item, quit := s.queue.Get()
|
||||||
if quit {
|
if quit {
|
||||||
@ -54,78 +89,68 @@ func (s Sender) processNextEmail() bool {
|
|||||||
}
|
}
|
||||||
defer s.queue.Done(item)
|
defer s.queue.Done(item)
|
||||||
|
|
||||||
email, ok := item.(mail.Email)
|
path, ok := item.(string)
|
||||||
if !ok {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.SendMail(&email); err != nil {
|
email, err := mail.FromJSON(path)
|
||||||
log.Err(err).Msg("unable to send the email")
|
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 != "" {
|
// whatever the return, the email will be not enqueued again
|
||||||
if err := os.Remove(path); err != nil {
|
s.SendMail(email)
|
||||||
// this is a fatal error, can't send same e-mail indefinitely
|
|
||||||
if !os.IsExist(err) {
|
if err := os.Remove(path); err != nil {
|
||||||
log.Err(err).Str("path", path).Msg("unable to remove the JSON email")
|
// this is a fatal error, can't send same e-mail indefinitely
|
||||||
s.queue.Shutdown()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// run starts processing the queue.
|
// run starts processing the queue
|
||||||
func (s Sender) run() <-chan struct{} {
|
func (s Sender) run() <-chan struct{} {
|
||||||
chQueue := make(chan struct{})
|
queueCh := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
for s.processNextEmail() {
|
for s.processNextEmail() {
|
||||||
}
|
}
|
||||||
chQueue <- struct{}{}
|
queueCh <- struct{}{}
|
||||||
}()
|
}()
|
||||||
return chQueue
|
return queueCh
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run launches the queue processing, the outbox watcher and the HTTP server.
|
// Run launches the queue processing and the outbox watcher
|
||||||
// It catches `SIGINT` and `SIGTERM` to properly stopped the queue and the services.
|
// catches `SIGINT` and `SIGTERM` to properly stopped the queue
|
||||||
func (s Sender) Run() {
|
func (s Sender) Run() {
|
||||||
ctx, fnCancel := context.WithCancel(context.Background())
|
s.logger.Log("msg", "sender service is running")
|
||||||
|
|
||||||
chSignal := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(chSignal, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
chQueue := s.run()
|
s.watchOutbox()
|
||||||
|
queueCh := 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...")
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-chSignal:
|
case <-sigCh:
|
||||||
log.Warn().Msg("stop signal received, stopping...")
|
s.logger.Log("msg", "stop signal received, stopping e-mail queue...")
|
||||||
fnCancel()
|
s.queue.Shutdown()
|
||||||
case <-watcher.Done():
|
case <-queueCh:
|
||||||
log.Warn().Msg("watcher is done, stopping...")
|
s.logger.Log("msg", "e-mail queue stopped successfully")
|
||||||
fnCancel()
|
|
||||||
case <-server.Done():
|
|
||||||
log.Warn().Msg("server is done, stopping...")
|
|
||||||
fnCancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-server.Done()
|
s.logger.Log("msg", "sender service stopped successfully")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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