diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index d2542c81238..2680f82c0e4 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -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 + }) +} diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index 8e641d4e657..7136dd75b23 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -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", - "severity", - "warning\n", - "critical\n", - "alertname", - "FiringTwo\n", - "FiringOne\n", - "Hi, this is a custom template. - {{ if gt (len .Alerts.Firing) 0 }} -
    - {{range .Alerts.Firing }}
  1. Firing: {{ .Labels.alertname }} at {{ .Labels.severity }}
  2. {{ end }} -
- {{ 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\" ", 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 -} diff --git a/pkg/services/ngalert/notifier/channels/testing.go b/pkg/services/ngalert/notifier/channels/testing.go index 1247058db23..441a3328939 100644 --- a/pkg/services/ngalert/notifier/channels/testing.go +++ b/pkg/services/ngalert/notifier/channels/testing.go @@ -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} -} diff --git a/pkg/services/ngalert/notifier/email_test.go b/pkg/services/ngalert/notifier/email_test.go new file mode 100644 index 00000000000..217b4a037f2 --- /dev/null +++ b/pkg/services/ngalert/notifier/email_test.go @@ -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", + "severity", + "warning\n", + "critical\n", + "alertname", + "FiringTwo\n", + "FiringOne\n", + "
Hi, this is a custom template. + {{ if gt (len .Alerts.Firing) 0 }} +
    + {{range .Alerts.Firing }}
  1. Firing: {{ .Labels.alertname }} at {{ .Labels.severity }}
  2. {{ end }} +
+ {{ 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\" ", 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 +}