Alerting: Refactor email notifier (#60602)

* refactor email to not use simplejson

* add tests

* split integration test and unit test + more unit-tests

* Remove outdated comment

Co-authored-by: Armand Grillet <2117580+armandgrillet@users.noreply.github.com>
This commit is contained in:
Yuri Tseretyan 2022-12-21 02:03:15 -05:00 committed by GitHub
parent d070032065
commit dc2ca80f4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 513 additions and 326 deletions

View File

@ -2,37 +2,33 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types" "github.com/prometheus/alertmanager/types"
"github.com/grafana/alerting/alerting/notifier/channels" "github.com/grafana/alerting/alerting/notifier/channels"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/util"
) )
// EmailNotifier is responsible for sending // EmailNotifier is responsible for sending
// alert notifications over email. // alert notifications over email.
type EmailNotifier struct { type EmailNotifier struct {
*channels.Base *channels.Base
Addresses []string
SingleEmail bool
Message string
Subject string
log channels.Logger log channels.Logger
ns channels.EmailSender ns channels.EmailSender
images channels.ImageStore images channels.ImageStore
tmpl *template.Template tmpl *template.Template
settings *emailSettings
} }
type EmailConfig struct { type emailSettings struct {
*channels.NotificationChannelConfig
SingleEmail bool SingleEmail bool
Addresses []string Addresses []string
Message string Message string
@ -40,50 +36,60 @@ type EmailConfig struct {
} }
func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
cfg, err := NewEmailConfig(fc.Config) notifier, err := buildEmailNotifier(fc)
if err != nil { if err != nil {
return nil, receiverInitError{ return nil, receiverInitError{
Reason: err.Error(), Reason: err.Error(),
Cfg: *fc.Config, Cfg: *fc.Config,
} }
} }
return NewEmailNotifier(cfg, fc.Logger, fc.NotificationService, fc.ImageStore, fc.Template), nil return notifier, nil
} }
func NewEmailConfig(config *channels.NotificationChannelConfig) (*EmailConfig, error) { func buildEmailSettings(fc channels.FactoryConfig) (*emailSettings, error) {
settings, err := simplejson.NewJson(config.Settings) type emailSettingsRaw struct {
if err != nil { SingleEmail bool `json:"singleEmail,omitempty"`
return nil, err Addresses string `json:"addresses,omitempty"`
Message string `json:"message,omitempty"`
Subject string `json:"subject,omitempty"`
} }
addressesString := settings.Get("addresses").MustString()
if addressesString == "" { var settings emailSettingsRaw
err := json.Unmarshal(fc.Config.Settings, &settings)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
}
if settings.Addresses == "" {
return nil, errors.New("could not find addresses in settings") return nil, errors.New("could not find addresses in settings")
} }
// split addresses with a few different ways // split addresses with a few different ways
addresses := util.SplitEmails(addressesString) addresses := splitEmails(settings.Addresses)
return &EmailConfig{
NotificationChannelConfig: config, if settings.Subject == "" {
SingleEmail: settings.Get("singleEmail").MustBool(false), settings.Subject = channels.DefaultMessageTitleEmbed
Message: settings.Get("message").MustString(), }
Subject: settings.Get("subject").MustString(channels.DefaultMessageTitleEmbed),
return &emailSettings{
SingleEmail: settings.SingleEmail,
Message: settings.Message,
Subject: settings.Subject,
Addresses: addresses, Addresses: addresses,
}, nil }, nil
} }
// NewEmailNotifier is the constructor function func buildEmailNotifier(fc channels.FactoryConfig) (*EmailNotifier, error) {
// for the EmailNotifier. settings, err := buildEmailSettings(fc)
func NewEmailNotifier(config *EmailConfig, l channels.Logger, ns channels.EmailSender, images channels.ImageStore, t *template.Template) *EmailNotifier { if err != nil {
return &EmailNotifier{ return nil, err
Base: channels.NewBase(config.NotificationChannelConfig),
Addresses: config.Addresses,
SingleEmail: config.SingleEmail,
Message: config.Message,
Subject: config.Subject,
log: l,
ns: ns,
images: images,
tmpl: t,
} }
return &EmailNotifier{
Base: channels.NewBase(fc.Config),
log: fc.Logger,
ns: fc.NotificationService,
images: fc.ImageStore,
tmpl: fc.Template,
settings: settings,
}, nil
} }
// Notify sends the alert notification. // Notify sends the alert notification.
@ -91,7 +97,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
var tmplErr error var tmplErr error
tmpl, data := channels.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) tmpl, data := channels.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr)
subject := tmpl(en.Subject) subject := tmpl(en.settings.Subject)
alertPageURL := en.tmpl.ExternalURL.String() alertPageURL := en.tmpl.ExternalURL.String()
ruleURL := en.tmpl.ExternalURL.String() ruleURL := en.tmpl.ExternalURL.String()
u, err := url.Parse(en.tmpl.ExternalURL.String()) u, err := url.Parse(en.tmpl.ExternalURL.String())
@ -127,7 +133,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
Subject: subject, Subject: subject,
Data: map[string]interface{}{ Data: map[string]interface{}{
"Title": subject, "Title": subject,
"Message": tmpl(en.Message), "Message": tmpl(en.settings.Message),
"Status": data.Status, "Status": data.Status,
"Alerts": data.Alerts, "Alerts": data.Alerts,
"GroupLabels": data.GroupLabels, "GroupLabels": data.GroupLabels,
@ -138,8 +144,8 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
"AlertPageUrl": alertPageURL, "AlertPageUrl": alertPageURL,
}, },
EmbeddedFiles: embeddedFiles, EmbeddedFiles: embeddedFiles,
To: en.Addresses, To: en.settings.Addresses,
SingleEmail: en.SingleEmail, SingleEmail: en.settings.SingleEmail,
Template: "ng_alert_notification", Template: "ng_alert_notification",
} }
@ -157,3 +163,13 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
func (en *EmailNotifier) SendResolved() bool { func (en *EmailNotifier) SendResolved() bool {
return !en.GetDisableResolveMessage() return !en.GetDisableResolveMessage()
} }
func splitEmails(emails string) []string {
return strings.FieldsFunc(emails, func(r rune) bool {
switch r {
case ',', ';', '\n':
return true
}
return false
})
}

View File

@ -11,30 +11,142 @@ import (
"github.com/prometheus/alertmanager/types" "github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/notifications"
) )
func TestEmailNotifier(t *testing.T) { func TestEmailNotifier_Init(t *testing.T) {
testCase := []struct {
Name string
Config json.RawMessage
Expected *emailSettings
ExpectedError string
}{
{
Name: "error if JSON is empty",
Config: json.RawMessage(`{}`),
ExpectedError: "could not find addresses in settings",
},
{
Name: "should split addresses separated by semicolon",
Config: json.RawMessage(`{
"addresses": "someops@example.com;somedev@example.com"
}`),
Expected: &emailSettings{
SingleEmail: false,
Addresses: []string{
"someops@example.com",
"somedev@example.com",
},
Message: "",
Subject: channels.DefaultMessageTitleEmbed,
},
},
{
Name: "should split addresses separated by comma",
Config: json.RawMessage(`{
"addresses": "someops@example.com,somedev@example.com"
}`),
Expected: &emailSettings{
SingleEmail: false,
Addresses: []string{
"someops@example.com",
"somedev@example.com",
},
Message: "",
Subject: channels.DefaultMessageTitleEmbed,
},
},
{
Name: "should split addresses separated by new-line",
Config: json.RawMessage(`{
"addresses": "someops@example.com\nsomedev@example.com"
}`),
Expected: &emailSettings{
SingleEmail: false,
Addresses: []string{
"someops@example.com",
"somedev@example.com",
},
Message: "",
Subject: channels.DefaultMessageTitleEmbed,
},
},
{
Name: "should split addresses separated by mixed separators",
Config: json.RawMessage(`{
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com"
}`),
Expected: &emailSettings{
SingleEmail: false,
Addresses: []string{
"someops@example.com",
"somedev@example.com",
"somedev2@example.com",
"somedev3@example.com",
},
Message: "",
Subject: channels.DefaultMessageTitleEmbed,
},
},
{
Name: "should split addresses separated by mixed separators",
Config: json.RawMessage(`{
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com"
}`),
Expected: &emailSettings{
SingleEmail: false,
Addresses: []string{
"someops@example.com",
"somedev@example.com",
"somedev2@example.com",
"somedev3@example.com",
},
Message: "",
Subject: channels.DefaultMessageTitleEmbed,
},
},
{
Name: "should parse all settings",
Config: json.RawMessage(`{
"singleEmail": true,
"addresses": "someops@example.com",
"message": "test-message",
"subject": "test-subject"
}`),
Expected: &emailSettings{
SingleEmail: true,
Addresses: []string{
"someops@example.com",
},
Message: "test-message",
Subject: "test-subject",
},
},
}
for _, test := range testCase {
t.Run(test.Name, func(t *testing.T) {
cfg := &channels.NotificationChannelConfig{
Name: "ops",
Type: "email",
Settings: test.Config,
}
settings, err := buildEmailSettings(channels.FactoryConfig{Config: cfg})
if test.ExpectedError != "" {
require.ErrorContains(t, err, test.ExpectedError)
} else {
require.Equal(t, *test.Expected, *settings)
}
})
}
}
func TestEmailNotifier_Notify(t *testing.T) {
tmpl := templateForTests(t) tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost/base") externalURL, err := url.Parse("http://localhost/base")
require.NoError(t, err) require.NoError(t, err)
tmpl.ExternalURL = externalURL tmpl.ExternalURL = externalURL
t.Run("empty settings should return error", func(t *testing.T) {
jsonData := `{ }`
settingsJSON := json.RawMessage(jsonData)
model := &channels.NotificationChannelConfig{
Name: "ops",
Type: "email",
Settings: settingsJSON,
}
_, err := NewEmailConfig(model)
require.Error(t, err)
})
t.Run("with the correct settings it should not fail and produce the expected command", func(t *testing.T) { t.Run("with the correct settings it should not fail and produce the expected command", func(t *testing.T) {
jsonData := `{ jsonData := `{
"addresses": "someops@example.com;somedev@example.com", "addresses": "someops@example.com;somedev@example.com",
@ -42,13 +154,24 @@ func TestEmailNotifier(t *testing.T) {
}` }`
emailSender := mockNotificationService() emailSender := mockNotificationService()
cfg, err := NewEmailConfig(&channels.NotificationChannelConfig{
fc := channels.FactoryConfig{
Config: &channels.NotificationChannelConfig{
Name: "ops", Name: "ops",
Type: "email", Type: "email",
Settings: json.RawMessage(jsonData), Settings: json.RawMessage(jsonData),
}) },
NotificationService: emailSender,
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
return fallback
},
ImageStore: &channels.UnavailableImageStore{},
Template: tmpl,
Logger: &channels.FakeLogger{},
}
emailNotifier, err := EmailFactory(fc)
require.NoError(t, err) require.NoError(t, err)
emailNotifier := NewEmailNotifier(cfg, &channels.FakeLogger{}, emailSender, &channels.UnavailableImageStore{}, tmpl)
alerts := []*types.Alert{ alerts := []*types.Alert{
{ {
@ -100,204 +223,3 @@ func TestEmailNotifier(t *testing.T) {
}, expected) }, expected)
}) })
} }
func TestEmailNotifierIntegration(t *testing.T) {
ns := createEmailSender(t)
emailTmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost/base")
require.NoError(t, err)
emailTmpl.ExternalURL = externalURL
cases := []struct {
name string
alerts []*types.Alert
messageTmpl string
subjectTmpl string
expSubject string
expSnippets []string
}{
{
name: "single alert with templated message",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `Hi, this is a custom template.
{{ if gt (len .Alerts.Firing) 0 }}
You have {{ len .Alerts.Firing }} alerts firing.
{{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }}
{{ end }}`,
expSubject: "[FIRING:1] (AlwaysFiring warning)",
expSnippets: []string{
"Hi, this is a custom template.",
"You have 1 alerts firing.",
"Firing: AlwaysFiring at warning",
},
},
{
name: "multiple alerts with templated message",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `Hi, this is a custom template.
{{ if gt (len .Alerts.Firing) 0 }}
You have {{ len .Alerts.Firing }} alerts firing.
{{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }}
{{ end }}`,
expSubject: "[FIRING:2] ",
expSnippets: []string{
"Hi, this is a custom template.",
"You have 2 alerts firing.",
"Firing: FiringOne at warning",
"Firing: FiringTwo at critical",
},
},
{
name: "empty message with alerts uses default template content",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: "",
expSubject: "[FIRING:2] ",
expSnippets: []string{
"2 firing instances",
"<strong>severity</strong>",
"warning\n",
"critical\n",
"<strong>alertname</strong>",
"FiringTwo\n",
"FiringOne\n",
"<a href=\"http://fix.me\"",
"<a href=\"http://localhost/base/d/abc",
"<a href=\"http://localhost/base/d/abc?viewPanel=5",
},
},
{
name: "message containing HTML gets HTMLencoded",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `<marquee>Hi, this is a custom template.</marquee>
{{ if gt (len .Alerts.Firing) 0 }}
<ol>
{{range .Alerts.Firing }}<li>Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} </li> {{ end }}
</ol>
{{ end }}`,
expSubject: "[FIRING:1] (AlwaysFiring warning)",
expSnippets: []string{
"&lt;marquee&gt;Hi, this is a custom template.&lt;/marquee&gt;",
"&lt;li&gt;Firing: AlwaysFiring at warning &lt;/li&gt;",
},
},
{
name: "single alert with templated subject",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
subjectTmpl: `This notification is {{ .Status }}!`,
expSubject: "This notification is firing!",
expSnippets: []string{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
emailNotifier := createSut(t, c.messageTmpl, c.subjectTmpl, emailTmpl, ns)
ok, err := emailNotifier.Notify(context.Background(), c.alerts...)
require.NoError(t, err)
require.True(t, ok)
sentMsg := getSingleSentMessage(t, ns)
require.NotNil(t, sentMsg)
require.Equal(t, "\"Grafana Admin\" <from@address.com>", sentMsg.From)
require.Equal(t, sentMsg.To[0], "someops@example.com")
require.Equal(t, c.expSubject, sentMsg.Subject)
require.Contains(t, sentMsg.Body, "text/html")
html := sentMsg.Body["text/html"]
require.NotNil(t, html)
for _, s := range c.expSnippets {
require.Contains(t, html, s)
}
})
}
}
func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *template.Template, ns *emailSender) *EmailNotifier {
t.Helper()
jsonData := map[string]interface{}{
"addresses": "someops@example.com;somedev@example.com",
"singleEmail": true,
}
if messageTmpl != "" {
jsonData["message"] = messageTmpl
}
if subjectTmpl != "" {
jsonData["subject"] = subjectTmpl
}
bytes, err := json.Marshal(jsonData)
require.NoError(t, err)
cfg, err := NewEmailConfig(&channels.NotificationChannelConfig{
Name: "ops",
Type: "email",
Settings: bytes,
})
require.NoError(t, err)
emailNotifier := NewEmailNotifier(cfg, &channels.FakeLogger{}, ns, &channels.UnavailableImageStore{}, emailTmpl)
return emailNotifier
}
func getSingleSentMessage(t *testing.T, ns *emailSender) *notifications.Message {
t.Helper()
mailer := ns.ns.GetMailer().(*notifications.FakeMailer)
require.Len(t, mailer.Sent, 1)
sent := mailer.Sent[0]
mailer.Sent = []*notifications.Message{}
return sent
}

View File

@ -3,17 +3,9 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"testing"
"time" "time"
"github.com/grafana/alerting/alerting/notifier/channels" "github.com/grafana/alerting/alerting/notifier/channels"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
) )
type fakeImageStore struct { type fakeImageStore struct {
@ -81,53 +73,3 @@ func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *channels.
} }
func mockNotificationService() *notificationServiceMock { return &notificationServiceMock{} } func mockNotificationService() *notificationServiceMock { return &notificationServiceMock{} }
type emailSender struct {
ns *notifications.NotificationService
}
func (e emailSender) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error {
attached := make([]*models.SendEmailAttachFile, 0, len(cmd.AttachedFiles))
for _, file := range cmd.AttachedFiles {
attached = append(attached, &models.SendEmailAttachFile{
Name: file.Name,
Content: file.Content,
})
}
return e.ns.SendEmailCommandHandlerSync(ctx, &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
To: cmd.To,
SingleEmail: cmd.SingleEmail,
Template: cmd.Template,
Subject: cmd.Subject,
Data: cmd.Data,
Info: cmd.Info,
ReplyTo: cmd.ReplyTo,
EmbeddedFiles: cmd.EmbeddedFiles,
AttachedFiles: attached,
},
})
}
func createEmailSender(t *testing.T) *emailSender {
t.Helper()
tracer := tracing.InitializeTracerForTest()
bus := bus.ProvideBus(tracer)
cfg := setting.NewCfg()
cfg.StaticRootPath = "../../../../../public/"
cfg.BuildVersion = "4.0.0"
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"}
cfg.Smtp.Host = "localhost:1234"
mailer := notifications.NewFakeMailer()
ns, err := notifications.ProvideService(bus, cfg, mailer, nil)
require.NoError(t, err)
return &emailSender{ns: ns}
}

View File

@ -0,0 +1,307 @@
package notifier
import (
"context"
"encoding/json"
"net/url"
"os"
"testing"
"github.com/grafana/alerting/alerting/notifier/channels"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
)
// TestEmailNotifierIntegration tests channels.EmailNotifier in conjunction with Grafana notifications.EmailSender and two staged expansion of the email body
func TestEmailNotifierIntegration(t *testing.T) {
ns := createEmailSender(t)
emailTmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost/base")
require.NoError(t, err)
emailTmpl.ExternalURL = externalURL
cases := []struct {
name string
alerts []*types.Alert
messageTmpl string
subjectTmpl string
expSubject string
expSnippets []string
}{
{
name: "single alert with templated message",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `Hi, this is a custom template.
{{ if gt (len .Alerts.Firing) 0 }}
You have {{ len .Alerts.Firing }} alerts firing.
{{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }}
{{ end }}`,
expSubject: "[FIRING:1] (AlwaysFiring warning)",
expSnippets: []string{
"Hi, this is a custom template.",
"You have 1 alerts firing.",
"Firing: AlwaysFiring at warning",
},
},
{
name: "multiple alerts with templated message",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `Hi, this is a custom template.
{{ if gt (len .Alerts.Firing) 0 }}
You have {{ len .Alerts.Firing }} alerts firing.
{{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }}
{{ end }}`,
expSubject: "[FIRING:2] ",
expSnippets: []string{
"Hi, this is a custom template.",
"You have 2 alerts firing.",
"Firing: FiringOne at warning",
"Firing: FiringTwo at critical",
},
},
{
name: "empty message with alerts uses default template content",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: "",
expSubject: "[FIRING:2] ",
expSnippets: []string{
"2 firing instances",
"<strong>severity</strong>",
"warning\n",
"critical\n",
"<strong>alertname</strong>",
"FiringTwo\n",
"FiringOne\n",
"<a href=\"http://fix.me\"",
"<a href=\"http://localhost/base/d/abc",
"<a href=\"http://localhost/base/d/abc?viewPanel=5",
},
},
{
name: "message containing HTML gets HTMLencoded",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
messageTmpl: `<marquee>Hi, this is a custom template.</marquee>
{{ if gt (len .Alerts.Firing) 0 }}
<ol>
{{range .Alerts.Firing }}<li>Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} </li> {{ end }}
</ol>
{{ end }}`,
expSubject: "[FIRING:1] (AlwaysFiring warning)",
expSnippets: []string{
"&lt;marquee&gt;Hi, this is a custom template.&lt;/marquee&gt;",
"&lt;li&gt;Firing: AlwaysFiring at warning &lt;/li&gt;",
},
},
{
name: "single alert with templated subject",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
},
},
},
subjectTmpl: `This notification is {{ .Status }}!`,
expSubject: "This notification is firing!",
expSnippets: []string{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
emailNotifier := createSut(t, c.messageTmpl, c.subjectTmpl, emailTmpl, ns)
ok, err := emailNotifier.Notify(context.Background(), c.alerts...)
require.NoError(t, err)
require.True(t, ok)
sentMsg := getSingleSentMessage(t, ns)
require.NotNil(t, sentMsg)
require.Equal(t, "\"Grafana Admin\" <from@address.com>", sentMsg.From)
require.Equal(t, sentMsg.To[0], "someops@example.com")
require.Equal(t, c.expSubject, sentMsg.Subject)
require.Contains(t, sentMsg.Body, "text/html")
html := sentMsg.Body["text/html"]
require.NotNil(t, html)
for _, s := range c.expSnippets {
require.Contains(t, html, s)
}
})
}
}
func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *template.Template, ns channels.NotificationSender) channels.NotificationChannel {
t.Helper()
jsonData := map[string]interface{}{
"addresses": "someops@example.com;somedev@example.com",
"singleEmail": true,
}
if messageTmpl != "" {
jsonData["message"] = messageTmpl
}
if subjectTmpl != "" {
jsonData["subject"] = subjectTmpl
}
bytes, err := json.Marshal(jsonData)
require.NoError(t, err)
fc := channels.FactoryConfig{
Config: &channels.NotificationChannelConfig{
Name: "ops",
Type: "email",
Settings: json.RawMessage(bytes),
},
NotificationService: ns,
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
return fallback
},
ImageStore: &channels.UnavailableImageStore{},
Template: emailTmpl,
Logger: &channels.FakeLogger{},
}
emailNotifier, err := ngchannels.EmailFactory(fc)
require.NoError(t, err)
return emailNotifier
}
func getSingleSentMessage(t *testing.T, ns *emailSender) *notifications.Message {
t.Helper()
mailer := ns.ns.GetMailer().(*notifications.FakeMailer)
require.Len(t, mailer.Sent, 1)
sent := mailer.Sent[0]
mailer.Sent = []*notifications.Message{}
return sent
}
type emailSender struct {
ns *notifications.NotificationService
}
func (e emailSender) SendWebhook(ctx context.Context, cmd *channels.SendWebhookSettings) error {
panic("not implemented")
}
func (e emailSender) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error {
attached := make([]*models.SendEmailAttachFile, 0, len(cmd.AttachedFiles))
for _, file := range cmd.AttachedFiles {
attached = append(attached, &models.SendEmailAttachFile{
Name: file.Name,
Content: file.Content,
})
}
return e.ns.SendEmailCommandHandlerSync(ctx, &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
To: cmd.To,
SingleEmail: cmd.SingleEmail,
Template: cmd.Template,
Subject: cmd.Subject,
Data: cmd.Data,
Info: cmd.Info,
ReplyTo: cmd.ReplyTo,
EmbeddedFiles: cmd.EmbeddedFiles,
AttachedFiles: attached,
},
})
}
func createEmailSender(t *testing.T) *emailSender {
t.Helper()
tracer := tracing.InitializeTracerForTest()
bus := bus.ProvideBus(tracer)
cfg := setting.NewCfg()
cfg.StaticRootPath = "../../../../public/"
cfg.BuildVersion = "4.0.0"
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"}
cfg.Smtp.Host = "localhost:1234"
mailer := notifications.NewFakeMailer()
ns, err := notifications.ProvideService(bus, cfg, mailer, nil)
require.NoError(t, err)
return &emailSender{ns: ns}
}
func templateForTests(t *testing.T) *template.Template {
f, err := os.CreateTemp("/tmp", "template")
require.NoError(t, err)
defer func(f *os.File) {
_ = f.Close()
}(f)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(f.Name()))
})
_, err = f.WriteString(channels.TemplateForTestsString)
require.NoError(t, err)
tmpl, err := template.FromGlobs(f.Name())
require.NoError(t, err)
return tmpl
}