mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d070032065
commit
dc2ca80f4d
@ -2,37 +2,33 @@ package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
|
||||
"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
|
||||
// alert notifications over email.
|
||||
type EmailNotifier struct {
|
||||
*channels.Base
|
||||
Addresses []string
|
||||
SingleEmail bool
|
||||
Message string
|
||||
Subject string
|
||||
log channels.Logger
|
||||
ns channels.EmailSender
|
||||
images channels.ImageStore
|
||||
tmpl *template.Template
|
||||
log channels.Logger
|
||||
ns channels.EmailSender
|
||||
images channels.ImageStore
|
||||
tmpl *template.Template
|
||||
settings *emailSettings
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
*channels.NotificationChannelConfig
|
||||
type emailSettings struct {
|
||||
SingleEmail bool
|
||||
Addresses []string
|
||||
Message string
|
||||
@ -40,50 +36,60 @@ type EmailConfig struct {
|
||||
}
|
||||
|
||||
func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
||||
cfg, err := NewEmailConfig(fc.Config)
|
||||
notifier, err := buildEmailNotifier(fc)
|
||||
if err != nil {
|
||||
return nil, receiverInitError{
|
||||
Reason: err.Error(),
|
||||
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) {
|
||||
settings, err := simplejson.NewJson(config.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func buildEmailSettings(fc channels.FactoryConfig) (*emailSettings, error) {
|
||||
type emailSettingsRaw struct {
|
||||
SingleEmail bool `json:"singleEmail,omitempty"`
|
||||
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")
|
||||
}
|
||||
// split addresses with a few different ways
|
||||
addresses := util.SplitEmails(addressesString)
|
||||
return &EmailConfig{
|
||||
NotificationChannelConfig: config,
|
||||
SingleEmail: settings.Get("singleEmail").MustBool(false),
|
||||
Message: settings.Get("message").MustString(),
|
||||
Subject: settings.Get("subject").MustString(channels.DefaultMessageTitleEmbed),
|
||||
Addresses: addresses,
|
||||
addresses := splitEmails(settings.Addresses)
|
||||
|
||||
if settings.Subject == "" {
|
||||
settings.Subject = channels.DefaultMessageTitleEmbed
|
||||
}
|
||||
|
||||
return &emailSettings{
|
||||
SingleEmail: settings.SingleEmail,
|
||||
Message: settings.Message,
|
||||
Subject: settings.Subject,
|
||||
Addresses: addresses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewEmailNotifier is the constructor function
|
||||
// for the EmailNotifier.
|
||||
func NewEmailNotifier(config *EmailConfig, l channels.Logger, ns channels.EmailSender, images channels.ImageStore, t *template.Template) *EmailNotifier {
|
||||
return &EmailNotifier{
|
||||
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,
|
||||
func buildEmailNotifier(fc channels.FactoryConfig) (*EmailNotifier, error) {
|
||||
settings, err := buildEmailSettings(fc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
@ -91,7 +97,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
|
||||
var tmplErr error
|
||||
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()
|
||||
ruleURL := 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,
|
||||
Data: map[string]interface{}{
|
||||
"Title": subject,
|
||||
"Message": tmpl(en.Message),
|
||||
"Message": tmpl(en.settings.Message),
|
||||
"Status": data.Status,
|
||||
"Alerts": data.Alerts,
|
||||
"GroupLabels": data.GroupLabels,
|
||||
@ -138,8 +144,8 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
|
||||
"AlertPageUrl": alertPageURL,
|
||||
},
|
||||
EmbeddedFiles: embeddedFiles,
|
||||
To: en.Addresses,
|
||||
SingleEmail: en.SingleEmail,
|
||||
To: en.settings.Addresses,
|
||||
SingleEmail: en.settings.SingleEmail,
|
||||
Template: "ng_alert_notification",
|
||||
}
|
||||
|
||||
@ -157,3 +163,13 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo
|
||||
func (en *EmailNotifier) SendResolved() bool {
|
||||
return !en.GetDisableResolveMessage()
|
||||
}
|
||||
|
||||
func splitEmails(emails string) []string {
|
||||
return strings.FieldsFunc(emails, func(r rune) bool {
|
||||
switch r {
|
||||
case ',', ';', '\n':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
@ -11,30 +11,142 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"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)
|
||||
|
||||
externalURL, err := url.Parse("http://localhost/base")
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
jsonData := `{
|
||||
"addresses": "someops@example.com;somedev@example.com",
|
||||
@ -42,13 +154,24 @@ func TestEmailNotifier(t *testing.T) {
|
||||
}`
|
||||
|
||||
emailSender := mockNotificationService()
|
||||
cfg, err := NewEmailConfig(&channels.NotificationChannelConfig{
|
||||
Name: "ops",
|
||||
Type: "email",
|
||||
Settings: json.RawMessage(jsonData),
|
||||
})
|
||||
|
||||
fc := channels.FactoryConfig{
|
||||
Config: &channels.NotificationChannelConfig{
|
||||
Name: "ops",
|
||||
Type: "email",
|
||||
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)
|
||||
emailNotifier := NewEmailNotifier(cfg, &channels.FakeLogger{}, emailSender, &channels.UnavailableImageStore{}, tmpl)
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
@ -100,204 +223,3 @@ func TestEmailNotifier(t *testing.T) {
|
||||
}, 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{
|
||||
"<marquee>Hi, this is a custom template.</marquee>",
|
||||
"<li>Firing: AlwaysFiring at warning </li>",
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
|
@ -3,17 +3,9 @@ package channels
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
@ -81,53 +73,3 @@ func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *channels.
|
||||
}
|
||||
|
||||
func mockNotificationService() *notificationServiceMock { return ¬ificationServiceMock{} }
|
||||
|
||||
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}
|
||||
}
|
||||
|
307
pkg/services/ngalert/notifier/email_test.go
Normal file
307
pkg/services/ngalert/notifier/email_test.go
Normal 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{
|
||||
"<marquee>Hi, this is a custom template.</marquee>",
|
||||
"<li>Firing: AlwaysFiring at warning </li>",
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
Loading…
Reference in New Issue
Block a user