mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
8114f6b065
commit
c68eefd398
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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"))
|
||||
})
|
||||
}
|
@ -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,
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
147
pkg/services/notifications/smtp.go
Normal file
147
pkg/services/notifications/smtp.go
Normal file
@ -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
|
||||
}
|
||||
}
|
108
pkg/services/notifications/smtp_test.go
Normal file
108
pkg/services/notifications/smtp_test.go
Normal file
@ -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")
|
||||
})
|
||||
}
|
32
pkg/services/notifications/testing.go
Normal file
32
pkg/services/notifications/testing.go
Normal file
@ -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")
|
||||
}
|
Loading…
Reference in New Issue
Block a user