mirror of https://github.com/grafana/grafana
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 Georgepull/43783/head
parent
8114f6b065
commit
c68eefd398
@ -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")) |
||||
}) |
||||
} |
@ -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 |
||||
} |
||||
} |
@ -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") |
||||
}) |
||||
} |
@ -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