diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..6010b74 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,18 @@ +name: publish + +on: + push: + branches: [ 'master' ] + +jobs: + publish: + name: publish + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + - uses: actions/checkout@v3 + - uses: ko-build/setup-ko@v0.6 + - run: ko build \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b216ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.env +.idea +.DS_Store \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f3d6c4 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +ifneq (,$(wildcard ./.env)) + include .env + export +endif + +run: + @go run main.go + +integration: + @go test -v ./... -tags=integration + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a2e154 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +### Erida: Simplifying Internal Cluster Communication + +**Erida** is a straightforward SMTP relay server designed for sending internal cluster emails to an authenticated SMTP +server with seamless Slack integration. + +### Email Address Flexibility + +Erida supports a variety of email addresses, including common ones like `fadyat@icloud.com` and those associated with +messaging services, particularly Slack. + +For Slack integration, you can use addresses following this syntax: + +- User-specific Slack address: `personal.@slack` +- Channel-specific Slack address: `channel.@slack` + +It's important to note that both the `username` and `channelname` are case-insensitive, and the bot must have the +necessary +permissions to access the specified Slack channels. + +### Configuration Made Easy + +Configuring Erida is a breeze. All you need to do is set the following environment variables: + +- `SMTP_HOST`: SMTP server host +- `SMTP_PORT`: SMTP server port +- `SMTP_USER`: SMTP server username +- `SMTP_PASSWORD`: SMTP server password +- `SLACK_TOKEN`: Slack bot token +- `SMTP_TLS` (Optional, default: true): Enable or disable Start TLS usage + +For additional variables, refer to the [configuration file](internal/config.go). + +### Getting Started + +If you're new to configuring the bot, check out the step-by-step guide +at [Slack Quickstart](https://api.slack.com/start/quickstart). + +Ensure that the bot has the necessary permissions, specifically `chat:write`. + +### Example Usage + +Let's walk through an example. Assume that an external SMTP server is configured to send emails to **Erida** with the +following addresses: `personal.fadyat@slack`, `channel.general@slack`, and `avfadeev@gmail.com`. + +The message will be seamlessly delivered to the Slack channel `#general` and the Slack user `@fadyat`, as well as to the +email address `avfadeev@gmail.com`. + +**Erida** simplifies internal communication, bridging the gap between email and Slack effortlessly. + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c02061 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/fadyat/erida + +go 1.21 + +require ( + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.19.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/mocktools/go-smtp-mock/v2 v2.1.0 + github.com/slack-go/slack v0.12.3 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/net v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..58830c9 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.19.0 h1:iVCDtR2/JY3RpKoaZ7u6I/sb52S3EzfNHO1fAWVHgng= +github.com/emersion/go-smtp v0.19.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mocktools/go-smtp-mock/v2 v2.1.0 h1:gGiWqlaMTExk7Id38G2+sWfOelsE+OAqJWAMsAI/654= +github.com/mocktools/go-smtp-mock/v2 v2.1.0/go.mod h1:n8aNpDYncZHH/cZHtJKzQyeYT/Dut00RghVM+J1Ed94= +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/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88= +github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/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= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/client.go b/internal/client.go new file mode 100644 index 0000000..5d09552 --- /dev/null +++ b/internal/client.go @@ -0,0 +1,106 @@ +package internal + +import ( + "fmt" + "github.com/slack-go/slack" + "strings" +) + +const ( + emailRecipient = "email" + slackRecipient = "slack" + + personalMessage = "personal." + channelMessage = "channel." +) + +var ( + operators = map[string]map[string]string{ + slackRecipient: { + personalMessage: "@", + channelMessage: "#", + }, + } +) + +type client interface { + + // proxify is a function, that will be used as a proxy for the data. + proxify(from string, to []string, body string) error +} + +func takeUsernames(recipients []string, recipientType string) []string { + var usernames = make([]string, 0, len(recipients)) + + for _, r := range recipients { + at := strings.Split(r, "@") + if len(at) != 2 { + continue + } + + if at[1] != recipientType { + continue + } + + usernames = append(usernames, convertToRecipientWay(at[0], recipientType)) + } + + return usernames +} + +func convertToRecipientWay(username string, recipientType string) string { + if _, ok := operators[recipientType]; !ok { + return username + } + + var msgType string + switch { + case strings.HasPrefix(username, personalMessage): + msgType = personalMessage + case strings.HasPrefix(username, channelMessage): + msgType = channelMessage + } + + operator := operators[recipientType][msgType] + return operator + strings.TrimPrefix(username, msgType) +} + +func selectClientType(recipient string) (string, error) { + at := strings.Split(recipient, "@") + if len(at) != 2 { + return "", fmt.Errorf("invalid recipient: %s", recipient) + } + + domain := at[1] + if domain == slackRecipient { + return slackRecipient, nil + } + + return emailRecipient, nil +} + +func selectClient(clientType string, cfg *Config) (client, error) { + switch clientType { + case emailRecipient: + return newMailClient(cfg) + case slackRecipient: + return newSlackClient(slack.New(cfg.SlackToken)), nil + default: + return nil, fmt.Errorf("unknown client type: %s", clientType) + } +} + +func groupByClientType(recipients []string) (map[string][]string, error) { + groups := make(map[string][]string) + + for _, recipient := range recipients { + clientType, err := selectClientType(recipient) + if err != nil { + return nil, fmt.Errorf("failed to select client type: %w", err) + } + + groups[clientType] = append(groups[clientType], recipient) + } + + return groups, nil +} diff --git a/internal/client_test.go b/internal/client_test.go new file mode 100644 index 0000000..d02a944 --- /dev/null +++ b/internal/client_test.go @@ -0,0 +1,260 @@ +package internal + +import ( + "context" + "fmt" + smtpmock "github.com/mocktools/go-smtp-mock/v2" + "github.com/stretchr/testify/require" + "testing" +) + +func TestTakeUsernames(t *testing.T) { + testcases := []struct { + name string + recipients []string + recipientType string + expected []string + }{ + { + name: "success: slack", + recipients: []string{"personal.avfadeev@slack", "channel.global@slack"}, + recipientType: "slack", + expected: []string{"@avfadeev", "#global"}, + }, + { + name: "success: email", + recipients: []string{"avfadeev@gmail.com", "aboba@aboba.com"}, + recipientType: "gmail.com", + expected: []string{"avfadeev"}, + }, + { + name: "success: skipping invalid recipients", + recipients: []string{ + "personal.avfadeev@slack", + "channel.global@slack", + "avfadeev@gmail.com", + }, + recipientType: "slack", + expected: []string{"@avfadeev", "#global"}, + }, + { + name: "success: skipping because of invalid size", + recipients: []string{ + "personal.avfadeev@slack", + "channel.global@slack", + "personal.avfadeev-slack", + }, + recipientType: "slack", + expected: []string{"@avfadeev", "#global"}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := takeUsernames(tc.recipients, tc.recipientType) + require.Equal(t, tc.expected, got) + }) + } +} + +func TestConvertToRecipientWay(t *testing.T) { + testcases := []struct { + name string + username string + recipientType string + expected string + }{ + { + name: "success: slack personal", + username: "personal.avfadeev", + recipientType: "slack", + expected: "@avfadeev", + }, + { + name: "success: slack channel", + username: "channel.global", + recipientType: "slack", + expected: "#global", + }, + { + name: "success: email", + username: "avfadeev@gmail.com", + recipientType: "email", + expected: "avfadeev@gmail.com", + }, + { + name: "success: unknown recipient type", + username: "something", + recipientType: "unknown", + expected: "something", + }, + { + name: "success: personal without prefix", + username: "personalavfadeev", + recipientType: "slack", + expected: "personalavfadeev", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := convertToRecipientWay(tc.username, tc.recipientType) + require.Equal(t, tc.expected, got) + }) + } +} + +func TestSelectClientType(t *testing.T) { + testcases := []struct { + name string + input string + want string + wantErr error + }{ + { + name: "success: slack", + input: "personal.avfadeev@slack", + want: "slack", + }, + { + name: "success: email", + input: "avfadeev@gmail.com", + want: "email", + }, + { + name: "error: invalid recipient", + input: "avfadeev", + wantErr: fmt.Errorf("invalid recipient: %s", "avfadeev"), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := selectClientType(tc.input) + if err != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + + require.Equal(t, tc.want, got) + }) + } +} + +func TestSelectClient(t *testing.T) { + testcases := []struct { + name string + pre func() context.Context + clientType string + wantErr error + }{ + { + name: "success: slack", + clientType: "slack", + }, + { + name: "success: email", + clientType: "email", + pre: func() context.Context { + srv := smtpmock.New(smtpmock.ConfigurationAttr{ + HostAddress: "localhost", + PortNumber: 2025, + }) + + go func() { + require.NoError(t, srv.Start()) + }() + + ctx := context.Background() + go func() { + <-ctx.Done() + require.NoError(t, srv.Stop()) + }() + + return ctx + }, + }, + { + name: "error: invalid client type", + clientType: "invalid", + wantErr: fmt.Errorf("unknown client type: %s", "invalid"), + }, + } + + cfg := &Config{ + Host: "localhost", + Port: "2025", + SlackToken: "slack-token", + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if tc.pre != nil { + ctx := tc.pre() + defer ctx.Done() + } + + got, err := selectClient(tc.clientType, cfg) + if err != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + + require.NotNil(t, got) + }) + } +} + +func TestGroupByClientType(t *testing.T) { + testcases := []struct { + name string + input []string + expected map[string][]string + wantErr error + }{ + { + name: "success: slack", + input: []string{"personal.avfadeev@slack"}, + expected: map[string][]string{ + "slack": {"personal.avfadeev@slack"}, + }, + }, + { + name: "success: email", + input: []string{"avfadeev@gmail.com"}, + expected: map[string][]string{ + "email": {"avfadeev@gmail.com"}, + }, + }, + { + name: "success: mixed", + input: []string{"personal.avfadeev@slack", "channel.global@slack", "avfadeev@gmail.com"}, + expected: map[string][]string{ + "slack": {"personal.avfadeev@slack", "channel.global@slack"}, + "email": {"avfadeev@gmail.com"}, + }, + }, + { + name: "success: empty", + input: []string{}, + expected: map[string][]string{}, + }, + { + name: "error: invalid recipient", + input: []string{"avfadeev"}, + wantErr: fmt.Errorf("failed to select client type: invalid recipient: %s", "avfadeev"), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := groupByClientType(tc.input) + if err != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + + require.Equal(t, tc.expected, got) + }) + } +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..410f44e --- /dev/null +++ b/internal/config.go @@ -0,0 +1,27 @@ +package internal + +import "fmt" + +type Config struct { + RelayHost string `env:"SERVER_HOST" env-default:"localhost"` + RelayPort string `env:"SERVER_PORT" env-default:"1025"` + Host string `env:"SMTP_HOST" env-default:"smtp.gmail.com"` + Port string `env:"SMTP_PORT" env-default:"587"` + User string `env:"SMTP_USER"` + Pass string `env:"SMTP_PASS"` + StartTLS bool `env:"SMTP_TLS" env-default:"true"` + Auth bool `env:"SMTP_AUTH" env-default:"true"` + SlackToken string `env:"SLACK_TOKEN" env-required:"true"` +} + +func (c *Config) Insecure() bool { + return !c.StartTLS +} + +func (c *Config) RelayAddr() string { + return fmt.Sprintf("%s:%s", c.RelayHost, c.RelayPort) +} + +func (c *Config) SMTPAddr() string { + return fmt.Sprintf("%s:%s", c.Host, c.Port) +} diff --git a/internal/mail_client.go b/internal/mail_client.go new file mode 100644 index 0000000..7210b26 --- /dev/null +++ b/internal/mail_client.go @@ -0,0 +1,83 @@ +package internal + +import ( + "crypto/tls" + "fmt" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "log/slog" + "strings" +) + +type mailClient struct { + *smtp.Client + cfg *Config +} + +func newMailClient(cfg *Config) (*mailClient, error) { + c, err := smtp.Dial(cfg.SMTPAddr()) + if err != nil { + return nil, fmt.Errorf("failed to dial: %w", err) + } + + return &mailClient{ + Client: c, + cfg: cfg, + }, nil +} + +func (c *mailClient) proxify(from string, to []string, body string) error { + defer func() { + if err := c.Quit(); err != nil { + slog.Error("failed to quit", "error", err) + } + }() + + if err := c.handshake(); err != nil { + return fmt.Errorf("failed to handshake: %w", err) + } + + slog.Info("sending message to", "recipients", to) + if err := c.SendMail(from, to, strings.NewReader(body)); err != nil { + return fmt.Errorf("failed to send mail: %w", err) + } + + return nil +} + +func (c *mailClient) handshake() error { + if err := c.startTLS(); err != nil { + return fmt.Errorf("failed to start tls: %w", err) + } + + return c.auth() +} + +func (c *mailClient) startTLS() error { + if !c.cfg.StartTLS { + return nil + } + + if err := c.StartTLS(&tls.Config{ + InsecureSkipVerify: c.cfg.Insecure(), + ServerName: c.cfg.Host, + }); err != nil { + return fmt.Errorf("failed to start tls: %w", err) + } + + return nil +} + +func (c *mailClient) auth() error { + if !c.cfg.Auth { + return nil + } + + if err := c.Auth( + sasl.NewLoginClient(c.cfg.User, c.cfg.Pass), + ); err != nil { + return fmt.Errorf("failed to auth: %w", err) + } + + return nil +} diff --git a/internal/server.go b/internal/server.go new file mode 100644 index 0000000..f4598e5 --- /dev/null +++ b/internal/server.go @@ -0,0 +1,103 @@ +package internal + +import ( + "fmt" + "github.com/emersion/go-smtp" + "io" + "log/slog" + "time" +) + +type backend struct { + cfg *Config +} + +func (b *backend) NewSession(_ *smtp.Conn) (smtp.Session, error) { + return &session{ + cfg: b.cfg, + }, nil +} + +type session struct { + cfg *Config + from string + body string + to []string +} + +func (s *session) Reset() { + clear(s.to) +} + +func (s *session) Logout() error { return nil } + +func (s *session) AuthPlain(_, _ string) error { + // AuthPlain is ignored, because in our case, we are accepting + // any username and password. + // + // Because it's launched in a local network, we don't care about + // security. + + return nil +} + +func (s *session) Mail(from string, _ *smtp.MailOptions) error { + s.from = from + return nil +} + +func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error { + s.to = append(s.to, to) + return nil +} + +func (s *session) Data(body io.Reader) error { + bodyRaw, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read body: %w", err) + } + s.body = string(bodyRaw) + + groups, err := groupByClientType(s.to) + if err != nil { + return fmt.Errorf("failed to group by client type: %w", err) + } + + for clientType, recipients := range groups { + c, e := selectClient(clientType, s.cfg) + if e != nil { + return fmt.Errorf("failed to select client: %w", e) + } + + if err = c.proxify(s.from, recipients, s.body); err != nil { + slog.Error( + "failed to proxify", + "error", err, + "clientType", clientType, + ) + } + } + + return nil +} + +type Server struct { + *smtp.Server +} + +func NewServer( + cfg *Config, +) *Server { + s := &Server{ + Server: smtp.NewServer(&backend{ + cfg: cfg, + }), + } + + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second + s.Addr = cfg.RelayAddr() + s.Domain = cfg.RelayHost + s.AllowInsecureAuth = cfg.Insecure() + return s +} diff --git a/internal/server_test.go b/internal/server_test.go new file mode 100644 index 0000000..7609ac8 --- /dev/null +++ b/internal/server_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "bytes" + "fmt" + "github.com/emersion/go-smtp" + smtpmock "github.com/mocktools/go-smtp-mock/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "strconv" + "strings" + "testing" +) + +const ( + relayHost = "localhost" + relayPort = 1025 + + host = "localhost" + port = 1026 +) + +func TestServer(t *testing.T) { + mockedServer := smtpmock.New(smtpmock.ConfigurationAttr{ + HostAddress: host, + PortNumber: port, + LogToStdout: true, + }) + + require.NoError(t, mockedServer.Start()) + server := NewServer(&Config{ + RelayHost: relayHost, + RelayPort: strconv.Itoa(relayPort), + Host: host, + Port: strconv.Itoa(port), + + // Mocked server doesn't support TLS and AUTH, disable them. + // https://github.com/mocktools/go-smtp-mock/issues/76 + // https://github.com/mocktools/go-smtp-mock/issues/84 + StartTLS: false, + Auth: false, + }) + go func() { require.NoError(t, server.ListenAndServe()) }() + + c, err := smtp.Dial(server.Addr) + require.NoError(t, err) + + var ( + from = "avfadeev@gmail.com" + to = []string{"to@icloud.com"} + body = []byte(`Subject: Hello +Content-Type: text/plain; charset=UTF-8 + +World! +`) + ) + + require.NoError(t, c.SendMail(from, to, bytes.NewReader(body))) + msgs := mockedServer.Messages() + require.Equal(t, 1, len(msgs)) + + msg := msgs[0] + assert.Equal(t, fmt.Sprintf("MAIL FROM:<%s>", from), msg.MailfromRequest()) + assert.Equal(t, "250 Received", msg.MailfromResponse()) + + rcpt := msg.RcpttoRequestResponse()[0] + assert.Equal(t, fmt.Sprintf("RCPT TO:<%s>", to[0]), rcpt[0]) + assert.Equal(t, "250 Received", rcpt[1]) + + assert.Equal(t, "DATA", msg.DataRequest()) + assert.Equal( + t, + "354 Ready for receive message. End data with .", + msg.DataResponse(), + ) + + assert.Equal( + t, + strings.Replace(string(body), "\n", "\r\n", -1), + msg.MsgRequest(), + ) + assert.Equal(t, "250 Received", msg.MsgResponse()) + + require.NoError(t, mockedServer.Stop()) + require.NoError(t, server.Close()) +} diff --git a/internal/slack_client.go b/internal/slack_client.go new file mode 100644 index 0000000..c84b1c3 --- /dev/null +++ b/internal/slack_client.go @@ -0,0 +1,89 @@ +package internal + +import ( + "fmt" + "github.com/slack-go/slack" + "log/slog" + "sync" +) + +//go:generate mockery --name=slackAPI --output=../mocks --exported +type slackAPI interface { + PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) +} + +type slackClient struct { + slackAPI +} + +func newSlackClient(sc slackAPI) *slackClient { + return &slackClient{ + slackAPI: sc, + } +} + +func (c *slackClient) proxify(from string, to []string, body string) error { + return c.sendMessageConcurrent( + takeUsernames(to, slackRecipient), + slack.MsgOptionBlocks( + slack.NewHeaderBlock( + slack.NewTextBlockObject( + slack.PlainTextType, + fmt.Sprintf("New message from %s", from), + false, + false, + ), + ), + slack.NewContextBlock( + "", + slack.NewTextBlockObject( + slack.PlainTextType, + body, + false, + false, + ), + ), + ), + ) +} + +func (c *slackClient) sendMessageConcurrent( + recipients []string, + msgOpts ...slack.MsgOption, +) error { + var ( + wg sync.WaitGroup + errorsChan = make(chan error) + msg = slack.MsgOptionCompose(msgOpts...) + ) + + slog.Info("sending message to", "recipients", recipients) + for _, user := range recipients { + wg.Add(1) + + go func(u string) { + defer wg.Done() + + _, _, err := c.PostMessage(u, msg) + if err != nil { + errorsChan <- err + } + }(user) + } + + go func() { + defer close(errorsChan) + wg.Wait() + }() + + var errors []error + for err := range errorsChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + return fmt.Errorf("failed to send messages: %s", errors) + } + + return nil +} diff --git a/internal/slack_client_test.go b/internal/slack_client_test.go new file mode 100644 index 0000000..50464ff --- /dev/null +++ b/internal/slack_client_test.go @@ -0,0 +1,83 @@ +package internal + +import ( + "errors" + "github.com/fadyat/erida/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func TestSlackClientProxify(t *testing.T) { + testcases := []struct { + name string + from string + to []string + body string + expect func(*mocks.SlackAPI) + wantErr error + }{ + { + name: "success: personal message", + from: "hellofrom", + to: []string{"personal.avfadeev@slack"}, + body: "hello", + expect: func(api *mocks.SlackAPI) { + api.On("PostMessage", "@avfadeev", mock.AnythingOfType("slack.MsgOption")). + Return("", "", nil) + }, + }, + { + name: "success: channel message", + from: "hellofrom", + to: []string{"channel.global@slack"}, + body: "hello", + expect: func(api *mocks.SlackAPI) { + api.On("PostMessage", "#global", mock.AnythingOfType("slack.MsgOption")). + Return("", "", nil) + }, + }, + { + name: "success: multiple recipients", + from: "hellofrom", + to: []string{"channel.global@slack", "personal.avfadeev@slack"}, + body: "hello", + expect: func(api *mocks.SlackAPI) { + api.On("PostMessage", "#global", mock.AnythingOfType("slack.MsgOption")). + Return("", "", nil) + api.On("PostMessage", "@avfadeev", mock.AnythingOfType("slack.MsgOption")). + Return("", "", nil) + }, + }, + { + name: "failed: some requests failed", + from: "hellofrom", + to: []string{"channel.global@slack", "personal.avfadeev@slack"}, + body: "hello", + expect: func(api *mocks.SlackAPI) { + api.On("PostMessage", "#global", mock.AnythingOfType("slack.MsgOption")). + Return("", "", nil) + api.On("PostMessage", "@avfadeev", mock.AnythingOfType("slack.MsgOption")). + Return("", "", errors.New("something went wrong")) + }, + wantErr: errors.New("failed to send messages: [something went wrong]"), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + api := mocks.NewSlackAPI(t) + c := newSlackClient(api) + + tc.expect(api) + err := c.proxify(tc.from, tc.to, tc.body) + if err != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + + require.Nil(t, err) + api.AssertExpectations(t) + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b9f00c7 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/fadyat/erida/internal" + "github.com/ilyakaznacheev/cleanenv" + "log/slog" +) + +func readConfig() (*internal.Config, error) { + var cfg internal.Config + if err := cleanenv.ReadEnv(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func main() { + cfg, err := readConfig() + if err != nil { + slog.Error("failed to read config", "error", err) + return + } + + srv := internal.NewServer(cfg) + slog.Info("Starting server at: ", "addr", srv.Addr) + if err = srv.ListenAndServe(); err != nil { + slog.Error("failed to start server: ", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..9a1aca2 --- /dev/null +++ b/main_test.go @@ -0,0 +1,77 @@ +//go:build integration + +package main + +import ( + "bytes" + "context" + "github.com/emersion/go-smtp" + "github.com/fadyat/erida/internal" + "github.com/ilyakaznacheev/cleanenv" + "github.com/stretchr/testify/require" + "log/slog" + "testing" + "time" +) + +const ( + testConfigPath = "../test.env" +) + +func readTestConfig() (*internal.Config, error) { + var cfg internal.Config + if err := cleanenv.ReadConfig(testConfigPath, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +// TestRelayFlow is an integration test, that will start a server and send a +// message to it. +// +// Need to run it with `go test -tags=integration ./...` +// +// Real Google GMail SMTP server and Slack API are used. +// Current test doesn't check the correctness of the message, that was sent. +// It only checks, that the server is able to send a message to the real services. +func TestRelayFlow(t *testing.T) { + cfg, err := readTestConfig() + require.NoError(t, err) + + srv := internal.NewServer(cfg) + go func() { + if err = srv.ListenAndServe(); err != nil { + slog.Error("failed to start server: ", err) + } + }() + + c, err := smtp.Dial(srv.Addr) + require.NoError(t, err) + + var ( + from = "erida@erida.com" + to = []string{ + "avfadeev@gmail.com", + "personal.fadyat@slack", + "channel.empty@slack", + } + body = []byte(`Subject: Integration test +Content-Type: text/plain; charset=UTF-8 + +Hello, this Erida! +We are testing the process of sending messages to Slack and GMail, +using this SMTP server as a relay. + +Best regards, Erida +`) + ) + + require.NoError(t, c.SendMail(from, to, bytes.NewReader(body))) + + // sleeping for 3 seconds to let the server process the message + time.Sleep(3 * time.Second) + + require.NoError(t, c.Quit()) + require.NoError(t, srv.Shutdown(context.Background())) +} diff --git a/mocks/slackAPI.go b/mocks/slackAPI.go new file mode 100644 index 0000000..9eb63cb --- /dev/null +++ b/mocks/slackAPI.go @@ -0,0 +1,65 @@ +// Code generated by mockery v2.33.1. DO NOT EDIT. + +package mocks + +import ( + slack "github.com/slack-go/slack" + mock "github.com/stretchr/testify/mock" +) + +// SlackAPI is an autogenerated mock type for the slackAPI type +type SlackAPI struct { + mock.Mock +} + +// PostMessage provides a mock function with given fields: channelID, options +func (_m *SlackAPI) PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, channelID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 string + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(string, ...slack.MsgOption) (string, string, error)); ok { + return rf(channelID, options...) + } + if rf, ok := ret.Get(0).(func(string, ...slack.MsgOption) string); ok { + r0 = rf(channelID, options...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, ...slack.MsgOption) string); ok { + r1 = rf(channelID, options...) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(string, ...slack.MsgOption) error); ok { + r2 = rf(channelID, options...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewSlackAPI creates a new instance of SlackAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSlackAPI(t interface { + mock.TestingT + Cleanup(func()) +}) *SlackAPI { + mock := &SlackAPI{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}