From c68eefd39870b0a2344abc98971392a31fda52ae Mon Sep 17 00:00:00 2001 From: Alexander Weaver Date: Thu, 13 Jan 2022 15:19:15 -0600 Subject: [PATCH] Alerting: Add abstraction layer and testing hooks in front of SMTP dialer (#43875) * Add abstraction layer above SMTP communication * Fix issues with attachments and sync command * Tests for bad SMTP behavior * Separate tests between async and sync entry points. Test difference between them * Return interface so Wire can properly map types * Address feedback from George --- pkg/server/wire.go | 1 + pkg/services/notifications/mailer.go | 130 +-------- pkg/services/notifications/mailer_test.go | 41 --- pkg/services/notifications/notifications.go | 5 +- .../notifications/notifications_test.go | 270 ++++++++++++++++-- pkg/services/notifications/smtp.go | 147 ++++++++++ pkg/services/notifications/smtp_test.go | 108 +++++++ pkg/services/notifications/testing.go | 32 +++ 8 files changed, 551 insertions(+), 183 deletions(-) delete mode 100644 pkg/services/notifications/mailer_test.go create mode 100644 pkg/services/notifications/smtp.go create mode 100644 pkg/services/notifications/smtp_test.go create mode 100644 pkg/services/notifications/testing.go diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 340bc66ff17..a02c8e9a530 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -150,6 +150,7 @@ var wireBasicSet = wire.NewSet( libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, + notifications.ProvideSmtpService, tracing.ProvideService, metrics.ProvideService, testdatasource.ProvideService, diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index f278c865300..1ba3b85e40a 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -6,21 +6,13 @@ package notifications import ( "bytes" - "crypto/tls" "fmt" "html/template" - "io" - "net" "net/mail" - "strconv" - "strings" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util/errutil" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - gomail "gopkg.in/mail.v2" ) var ( @@ -42,6 +34,10 @@ func init() { }) } +type Mailer interface { + Send(messages ...*Message) (int, error) +} + func (ns *NotificationService) Send(msg *Message) (int, error) { messages := []*Message{} @@ -55,123 +51,7 @@ func (ns *NotificationService) Send(msg *Message) (int, error) { } } - return ns.dialAndSend(messages...) -} - -func (ns *NotificationService) dialAndSend(messages ...*Message) (int, error) { - sentEmailsCount := 0 - dialer, err := ns.createDialer() - if err != nil { - return sentEmailsCount, err - } - - for _, msg := range messages { - m := ns.buildEmail(msg) - - innerError := dialer.DialAndSend(m) - emailsSentTotal.Inc() - if innerError != nil { - // As gomail does not returned typed errors we have to parse the error - // to catch invalid error when the address is invalid. - // https://github.com/go-gomail/gomail/blob/81ebce5c23dfd25c6c67194b37d3dd3f338c98b1/send.go#L113 - if !strings.HasPrefix(innerError.Error(), "gomail: invalid address") { - emailsSentFailed.Inc() - } - - err = errutil.Wrapf(innerError, "Failed to send notification to email addresses: %s", strings.Join(msg.To, ";")) - continue - } - - sentEmailsCount++ - } - - return sentEmailsCount, err -} - -func (ns *NotificationService) buildEmail(msg *Message) *gomail.Message { - m := gomail.NewMessage() - m.SetHeader("From", msg.From) - m.SetHeader("To", msg.To...) - m.SetHeader("Subject", msg.Subject) - ns.setFiles(m, msg) - for _, replyTo := range msg.ReplyTo { - m.SetAddressHeader("Reply-To", replyTo, "") - } - // loop over content types from settings in reverse order as they are ordered in according to descending - // preference while the alternatives should be ordered according to ascending preference - for i := len(ns.Cfg.Smtp.ContentTypes) - 1; i >= 0; i-- { - if i == len(ns.Cfg.Smtp.ContentTypes)-1 { - m.SetBody(ns.Cfg.Smtp.ContentTypes[i], msg.Body[ns.Cfg.Smtp.ContentTypes[i]]) - } else { - m.AddAlternative(ns.Cfg.Smtp.ContentTypes[i], msg.Body[ns.Cfg.Smtp.ContentTypes[i]]) - } - } - - return m -} - -// setFiles attaches files in various forms -func (ns *NotificationService) setFiles( - m *gomail.Message, - msg *Message, -) { - for _, file := range msg.EmbeddedFiles { - m.Embed(file) - } - - for _, file := range msg.AttachedFiles { - file := file - m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error { - _, err := writer.Write(file.Content) - return err - })) - } -} - -func (ns *NotificationService) createDialer() (*gomail.Dialer, error) { - host, port, err := net.SplitHostPort(ns.Cfg.Smtp.Host) - if err != nil { - return nil, err - } - iPort, err := strconv.Atoi(port) - if err != nil { - return nil, err - } - - tlsconfig := &tls.Config{ - InsecureSkipVerify: ns.Cfg.Smtp.SkipVerify, - ServerName: host, - } - - if ns.Cfg.Smtp.CertFile != "" { - cert, err := tls.LoadX509KeyPair(ns.Cfg.Smtp.CertFile, ns.Cfg.Smtp.KeyFile) - if err != nil { - return nil, fmt.Errorf("could not load cert or key file: %w", err) - } - tlsconfig.Certificates = []tls.Certificate{cert} - } - - d := gomail.NewDialer(host, iPort, ns.Cfg.Smtp.User, ns.Cfg.Smtp.Password) - d.TLSConfig = tlsconfig - d.StartTLSPolicy = getStartTLSPolicy(ns.Cfg.Smtp.StartTLSPolicy) - - if ns.Cfg.Smtp.EhloIdentity != "" { - d.LocalName = ns.Cfg.Smtp.EhloIdentity - } else { - d.LocalName = setting.InstanceName - } - return d, nil -} - -func getStartTLSPolicy(policy string) gomail.StartTLSPolicy { - switch policy { - case "NoStartTLS": - return -1 - case "MandatoryStartTLS": - return 1 - default: - return 0 - } + return ns.mailer.Send(messages...) } func (ns *NotificationService) buildEmailMessage(cmd *models.SendEmailCommand) (*Message, error) { diff --git a/pkg/services/notifications/mailer_test.go b/pkg/services/notifications/mailer_test.go deleted file mode 100644 index 263ec75abb6..00000000000 --- a/pkg/services/notifications/mailer_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package notifications - -import ( - "bytes" - "strings" - "testing" - - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildMail(t *testing.T) { - ns := &NotificationService{ - Cfg: setting.NewCfg(), - } - ns.Cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} - - message := &Message{ - To: []string{"to@address.com"}, - From: "from@address.com", - Subject: "Some subject", - Body: map[string]string{ - "text/html": "Some HTML body", - "text/plain": "Some plain text body", - }, - ReplyTo: []string{"from@address.com"}, - } - - t.Run("When building email", func(t *testing.T) { - email := ns.buildEmail(message) - - buf := new(bytes.Buffer) - _, err := email.WriteTo(buf) - require.NoError(t, err) - - assert.Contains(t, buf.String(), "Some HTML body") - assert.Contains(t, buf.String(), "Some plain text body") - assert.Less(t, strings.Index(buf.String(), "Some plain text body"), strings.Index(buf.String(), "Some HTML body")) - }) -} diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 60d500ea05f..b9bc854dfc5 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -22,13 +22,14 @@ var tmplResetPassword = "reset_password" var tmplSignUpStarted = "signup_started" var tmplWelcomeOnSignUp = "welcome_on_signup" -func ProvideService(bus bus.Bus, cfg *setting.Cfg) (*NotificationService, error) { +func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer) (*NotificationService, error) { ns := &NotificationService{ Bus: bus, Cfg: cfg, log: log.New("notifications"), mailQueue: make(chan *Message, 10), webhookQueue: make(chan *Webhook, 10), + mailer: mailer, } ns.Bus.AddHandler(ns.sendResetPasswordEmail) @@ -70,6 +71,7 @@ type NotificationService struct { mailQueue chan *Message webhookQueue chan *Webhook + mailer Mailer log log.Logger } @@ -125,6 +127,7 @@ func (ns *NotificationService) sendEmailCommandHandlerSync(ctx context.Context, To: cmd.To, SingleEmail: cmd.SingleEmail, EmbeddedFiles: cmd.EmbeddedFiles, + AttachedFiles: cmd.AttachedFiles, Subject: cmd.Subject, ReplyTo: cmd.ReplyTo, }) diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go index 55db3105569..3e40661dfab 100644 --- a/pkg/services/notifications/notifications_test.go +++ b/pkg/services/notifications/notifications_test.go @@ -11,30 +11,268 @@ import ( "github.com/stretchr/testify/require" ) -func TestNotificationService(t *testing.T) { - ns := &NotificationService{ - Cfg: setting.NewCfg(), - } - ns.Cfg.StaticRootPath = "../../../public/" - ns.Cfg.Smtp.Enabled = true - ns.Cfg.Smtp.TemplatesPatterns = []string{"emails/*.html", "emails/*.txt"} - ns.Cfg.Smtp.FromAddress = "from@address.com" - ns.Cfg.Smtp.FromName = "Grafana Admin" - ns.Cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} - ns.Bus = bus.New() +func TestProvideService(t *testing.T) { + bus := bus.New() - ns, err := ProvideService(bus.New(), ns.Cfg) - require.NoError(t, err) + t.Run("When invalid from_address in configuration", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.FromAddress = "@notanemail@" + _, _, err := createSutWithConfig(bus, cfg) - t.Run("When sending reset email password", func(t *testing.T) { - err := ns.sendResetPasswordEmail(context.Background(), &models.SendResetPasswordEmailCommand{User: &models.User{Email: "asd@asd.com"}}) + require.Error(t, err) + }) + + t.Run("When template_patterns fails to parse", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.TemplatesPatterns = append(cfg.Smtp.TemplatesPatterns, "/usr/not-a-dir/**") + _, _, err := createSutWithConfig(bus, cfg) + + require.Error(t, err) + }) +} + +func TestSendEmailSync(t *testing.T) { + bus := bus.New() + + t.Run("When sending emails synchronously", func(t *testing.T) { + _, mailer := createSut(t, bus) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"asdf@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + }, + } + + err := bus.Dispatch(context.Background(), cmd) require.NoError(t, err) - sentMsg := <-ns.mailQueue + require.NotEmpty(t, mailer.Sent) + sent := mailer.Sent[len(mailer.Sent)-1] + require.Equal(t, "subject", sent.Subject) + require.Equal(t, []string{"asdf@grafana.com"}, sent.To) + }) + + t.Run("When using Single Email mode with multiple recipients", func(t *testing.T) { + _, mailer := createSut(t, bus) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: true, + Template: "welcome_on_signup", + }, + } + + err := bus.Dispatch(context.Background(), cmd) + require.NoError(t, err) + + require.Len(t, mailer.Sent, 1) + }) + + t.Run("When using Multi Email mode with multiple recipients", func(t *testing.T) { + _, mailer := createSut(t, bus) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + }, + } + + err := bus.Dispatch(context.Background(), cmd) + require.NoError(t, err) + + require.Len(t, mailer.Sent, 3) + }) + + t.Run("When attaching files to emails", func(t *testing.T) { + _, mailer := createSut(t, bus) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"asdf@grafana.com"}, + SingleEmail: true, + Template: "welcome_on_signup", + AttachedFiles: []*models.SendEmailAttachFile{ + { + Name: "attachment.txt", + Content: []byte("text file content"), + }, + }, + }, + } + + err := bus.Dispatch(context.Background(), cmd) + require.NoError(t, err) + + require.NotEmpty(t, mailer.Sent) + sent := mailer.Sent[len(mailer.Sent)-1] + require.Len(t, sent.AttachedFiles, 1) + file := sent.AttachedFiles[len(sent.AttachedFiles)-1] + require.Equal(t, "attachment.txt", file.Name) + require.Equal(t, []byte("text file content"), file.Content) + }) + + t.Run("When SMTP disabled in configuration", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.Enabled = false + _, mailer, err := createSutWithConfig(bus, cfg) + require.NoError(t, err) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: true, + Template: "welcome_on_signup", + }, + } + + err = bus.Dispatch(context.Background(), cmd) + + require.ErrorIs(t, err, models.ErrSmtpNotEnabled) + require.Empty(t, mailer.Sent) + }) + + t.Run("When invalid content type in configuration", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data") + _, mailer, err := createSutWithConfig(bus, cfg) + require.NoError(t, err) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + }, + } + + err = bus.Dispatch(context.Background(), cmd) + + require.Error(t, err) + require.Empty(t, mailer.Sent) + }) + + t.Run("When SMTP dialer is disconnected", func(t *testing.T) { + _ = createDisconnectedSut(t, bus) + cmd := &models.SendEmailCommandSync{ + SendEmailCommand: models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + }, + } + + err := bus.Dispatch(context.Background(), cmd) + + require.Error(t, err) + }) +} + +func TestSendEmailAsync(t *testing.T) { + bus := bus.New() + + t.Run("When sending reset email password", func(t *testing.T) { + sut, _ := createSut(t, bus) + err := sut.sendResetPasswordEmail(context.Background(), &models.SendResetPasswordEmailCommand{User: &models.User{Email: "asd@asd.com"}}) + require.NoError(t, err) + + sentMsg := <-sut.mailQueue assert.Contains(t, sentMsg.Body["text/html"], "body") assert.NotContains(t, sentMsg.Body["text/plain"], "body") assert.Equal(t, "Reset your Grafana password - asd@asd.com", sentMsg.Subject) assert.NotContains(t, sentMsg.Body["text/html"], "Subject") assert.NotContains(t, sentMsg.Body["text/plain"], "Subject") }) + + t.Run("When SMTP disabled in configuration", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.Enabled = false + _, mailer, err := createSutWithConfig(bus, cfg) + require.NoError(t, err) + cmd := &models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: true, + Template: "welcome_on_signup", + } + + err = bus.Dispatch(context.Background(), cmd) + + require.ErrorIs(t, err, models.ErrSmtpNotEnabled) + require.Empty(t, mailer.Sent) + }) + + t.Run("When invalid content type in configuration", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data") + _, mailer, err := createSutWithConfig(bus, cfg) + require.NoError(t, err) + cmd := &models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + } + + err = bus.Dispatch(context.Background(), cmd) + + require.Error(t, err) + require.Empty(t, mailer.Sent) + }) + + t.Run("When SMTP dialer is disconnected", func(t *testing.T) { + _ = createDisconnectedSut(t, bus) + cmd := &models.SendEmailCommand{ + Subject: "subject", + To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, + SingleEmail: false, + Template: "welcome_on_signup", + } + + err := bus.Dispatch(context.Background(), cmd) + + // The async version should not surface connection errors via Bus. It should only log them. + require.NoError(t, err) + }) +} + +func createSut(t *testing.T, bus bus.Bus) (*NotificationService, *FakeMailer) { + t.Helper() + + cfg := createSmtpConfig() + ns, fm, err := createSutWithConfig(bus, cfg) + require.NoError(t, err) + return ns, fm +} + +func createSutWithConfig(bus bus.Bus, cfg *setting.Cfg) (*NotificationService, *FakeMailer, error) { + smtp := NewFakeMailer() + ns, err := ProvideService(bus, cfg, smtp) + return ns, smtp, err +} + +func createDisconnectedSut(t *testing.T, bus bus.Bus) *NotificationService { + t.Helper() + + cfg := createSmtpConfig() + smtp := NewFakeDisconnectedMailer() + ns, err := ProvideService(bus, cfg, smtp) + require.NoError(t, err) + return ns +} + +func createSmtpConfig() *setting.Cfg { + cfg := setting.NewCfg() + cfg.StaticRootPath = "../../../public/" + cfg.Smtp.Enabled = true + cfg.Smtp.TemplatesPatterns = []string{"emails/*.html", "emails/*.txt"} + cfg.Smtp.FromAddress = "from@address.com" + cfg.Smtp.FromName = "Grafana Admin" + cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} + return cfg } diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go new file mode 100644 index 00000000000..9b548c2de64 --- /dev/null +++ b/pkg/services/notifications/smtp.go @@ -0,0 +1,147 @@ +package notifications + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "strconv" + "strings" + + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" + gomail "gopkg.in/mail.v2" +) + +type SmtpClient struct { + cfg setting.SmtpSettings +} + +func ProvideSmtpService(cfg *setting.Cfg) (Mailer, error) { + return NewSmtpClient(cfg.Smtp) +} + +func NewSmtpClient(cfg setting.SmtpSettings) (*SmtpClient, error) { + client := &SmtpClient{ + cfg: cfg, + } + + return client, nil +} + +func (sc *SmtpClient) Send(messages ...*Message) (int, error) { + sentEmailsCount := 0 + dialer, err := sc.createDialer() + if err != nil { + return sentEmailsCount, err + } + + for _, msg := range messages { + m := sc.buildEmail(msg) + + innerError := dialer.DialAndSend(m) + emailsSentTotal.Inc() + if innerError != nil { + // As gomail does not returned typed errors we have to parse the error + // to catch invalid error when the address is invalid. + // https://github.com/go-gomail/gomail/blob/81ebce5c23dfd25c6c67194b37d3dd3f338c98b1/send.go#L113 + if !strings.HasPrefix(innerError.Error(), "gomail: invalid address") { + emailsSentFailed.Inc() + } + + err = errutil.Wrapf(innerError, "Failed to send notification to email addresses: %s", strings.Join(msg.To, ";")) + continue + } + + sentEmailsCount++ + } + + return sentEmailsCount, err +} + +// buildEmail converts the Message DTO to a gomail message. +func (sc *SmtpClient) buildEmail(msg *Message) *gomail.Message { + m := gomail.NewMessage() + m.SetHeader("From", msg.From) + m.SetHeader("To", msg.To...) + m.SetHeader("Subject", msg.Subject) + sc.setFiles(m, msg) + for _, replyTo := range msg.ReplyTo { + m.SetAddressHeader("Reply-To", replyTo, "") + } + // loop over content types from settings in reverse order as they are ordered in according to descending + // preference while the alternatives should be ordered according to ascending preference + for i := len(sc.cfg.ContentTypes) - 1; i >= 0; i-- { + if i == len(sc.cfg.ContentTypes)-1 { + m.SetBody(sc.cfg.ContentTypes[i], msg.Body[sc.cfg.ContentTypes[i]]) + } else { + m.AddAlternative(sc.cfg.ContentTypes[i], msg.Body[sc.cfg.ContentTypes[i]]) + } + } + + return m +} + +// setFiles attaches files in various forms. +func (sc *SmtpClient) setFiles( + m *gomail.Message, + msg *Message, +) { + for _, file := range msg.EmbeddedFiles { + m.Embed(file) + } + + for _, file := range msg.AttachedFiles { + file := file + m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error { + _, err := writer.Write(file.Content) + return err + })) + } +} + +func (sc *SmtpClient) createDialer() (*gomail.Dialer, error) { + host, port, err := net.SplitHostPort(sc.cfg.Host) + if err != nil { + return nil, err + } + iPort, err := strconv.Atoi(port) + if err != nil { + return nil, err + } + + tlsconfig := &tls.Config{ + InsecureSkipVerify: sc.cfg.SkipVerify, + ServerName: host, + } + + if sc.cfg.CertFile != "" { + cert, err := tls.LoadX509KeyPair(sc.cfg.CertFile, sc.cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("could not load cert or key file: %w", err) + } + tlsconfig.Certificates = []tls.Certificate{cert} + } + + d := gomail.NewDialer(host, iPort, sc.cfg.User, sc.cfg.Password) + d.TLSConfig = tlsconfig + d.StartTLSPolicy = getStartTLSPolicy(sc.cfg.StartTLSPolicy) + + if sc.cfg.EhloIdentity != "" { + d.LocalName = sc.cfg.EhloIdentity + } else { + d.LocalName = setting.InstanceName + } + return d, nil +} + +func getStartTLSPolicy(policy string) gomail.StartTLSPolicy { + switch policy { + case "NoStartTLS": + return -1 + case "MandatoryStartTLS": + return 1 + default: + return 0 + } +} diff --git a/pkg/services/notifications/smtp_test.go b/pkg/services/notifications/smtp_test.go new file mode 100644 index 00000000000..8ca5a11ed5d --- /dev/null +++ b/pkg/services/notifications/smtp_test.go @@ -0,0 +1,108 @@ +package notifications + +import ( + "bytes" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildMail(t *testing.T) { + cfg := setting.NewCfg() + cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} + + sc, err := NewSmtpClient(cfg.Smtp) + require.NoError(t, err) + + message := &Message{ + To: []string{"to@address.com"}, + From: "from@address.com", + Subject: "Some subject", + Body: map[string]string{ + "text/html": "Some HTML body", + "text/plain": "Some plain text body", + }, + ReplyTo: []string{"from@address.com"}, + } + + t.Run("When building email", func(t *testing.T) { + email := sc.buildEmail(message) + + buf := new(bytes.Buffer) + _, err := email.WriteTo(buf) + require.NoError(t, err) + + assert.Contains(t, buf.String(), "Some HTML body") + assert.Contains(t, buf.String(), "Some plain text body") + assert.Less(t, strings.Index(buf.String(), "Some plain text body"), strings.Index(buf.String(), "Some HTML body")) + }) +} + +func TestSmtpDialer(t *testing.T) { + t.Run("When SMTP hostname is invalid", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.Host = "invalid%hostname:123:456" + client, err := ProvideSmtpService(cfg) + require.NoError(t, err) + message := &Message{ + To: []string{"asdf@grafana.com"}, + SingleEmail: true, + Subject: "subject", + Body: map[string]string{ + "text/html": "body", + "text/plain": "body", + }, + } + + count, err := client.Send(message) + + require.Equal(t, 0, count) + require.EqualError(t, err, "address invalid%hostname:123:456: too many colons in address") + }) + + t.Run("When SMTP port is invalid", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.Host = "invalid%hostname:123a" + client, err := ProvideSmtpService(cfg) + require.NoError(t, err) + message := &Message{ + To: []string{"asdf@grafana.com"}, + SingleEmail: true, + Subject: "subject", + Body: map[string]string{ + "text/html": "body", + "text/plain": "body", + }, + } + + count, err := client.Send(message) + + require.Equal(t, 0, count) + require.EqualError(t, err, "strconv.Atoi: parsing \"123a\": invalid syntax") + }) + + t.Run("When TLS certificate does not exist", func(t *testing.T) { + cfg := createSmtpConfig() + cfg.Smtp.Host = "localhost:1234" + cfg.Smtp.CertFile = "/var/certs/does-not-exist.pem" + client, err := ProvideSmtpService(cfg) + require.NoError(t, err) + message := &Message{ + To: []string{"asdf@grafana.com"}, + SingleEmail: true, + Subject: "subject", + Body: map[string]string{ + "text/html": "body", + "text/plain": "body", + }, + } + + count, err := client.Send(message) + + require.Equal(t, 0, count) + require.EqualError(t, err, "could not load cert or key file: open /var/certs/does-not-exist.pem: no such file or directory") + }) +} diff --git a/pkg/services/notifications/testing.go b/pkg/services/notifications/testing.go new file mode 100644 index 00000000000..325ea0cc878 --- /dev/null +++ b/pkg/services/notifications/testing.go @@ -0,0 +1,32 @@ +package notifications + +import "fmt" + +type FakeMailer struct { + Sent []*Message +} + +func NewFakeMailer() *FakeMailer { + return &FakeMailer{ + Sent: make([]*Message, 0), + } +} + +func (fm *FakeMailer) Send(messages ...*Message) (int, error) { + sentEmailsCount := 0 + for _, msg := range messages { + fm.Sent = append(fm.Sent, msg) + sentEmailsCount++ + } + return sentEmailsCount, nil +} + +type FakeDisconnectedMailer struct{} + +func NewFakeDisconnectedMailer() *FakeDisconnectedMailer { + return &FakeDisconnectedMailer{} +} + +func (fdm *FakeDisconnectedMailer) Send(messages ...*Message) (int, error) { + return 0, fmt.Errorf("connect: connection refused") +}