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