Email: Allow configuration of content types for email notifications (#34530)

* Alerting: Allow configuration of content types for email notifications

* Fix lint error

* Improves email templates

* Improve configuration documentation

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve code comments

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve configuration documentation

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve email template

* Remove unnecessary predeclaration

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Adds handling for unrecognized content type

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Move utility function outside of util package

* Fixes syntax

* Remove unused package

* Fix lint error

* improve email templates

* Fix test

* Alerting: Allow configuration of content types for email notifications

* Fix lint error

* Improves email templates

* Improve configuration documentation

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve code comments

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve configuration documentation

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* Improve email template

* Remove unnecessary predeclaration

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Adds handling for unrecognized content type

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>

* Move utility function outside of util package

* Fixes syntax

* Remove unused package

* Fix lint error

* improve email templates

* Fix test

* Fix comment style

Co-authored-by: Ganesh Vernekar <15064823+codesome@users.noreply.github.com>

* Fix template formatting

* Add test and improve error handling

* Fix test

* Fix formatting

* Fix formatting

* Improve documentation and regenerates txt template

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

Co-authored-by: Djairho Geuens <djairho.geuens@ae.be>
Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
Co-authored-by: Ganesh Vernekar <15064823+codesome@users.noreply.github.com>
Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
Djairho Geuens
2021-07-19 13:31:51 +03:00
committed by GitHub
co-authored by Diana Payton Arve Knudsen Ganesh Vernekar achatterjee-grafana Djairho Geuens
parent cec12676e7
commit 4cadbba686
41 changed files with 408 additions and 58 deletions
+2 -2
View File
@@ -69,7 +69,7 @@ func AddOrgInvite(c *models.ReqContext, inviteDto dtos.AddInviteForm) response.R
if inviteDto.SendEmail && util.IsEmail(inviteDto.LoginOrEmail) {
emailCmd := models.SendEmailCommand{
To: []string{inviteDto.LoginOrEmail},
Template: "new_user_invite.html",
Template: "new_user_invite",
Data: map[string]interface{}{
"Name": util.StringsFallback2(cmd.Name, cmd.Email),
"OrgName": c.OrgName,
@@ -111,7 +111,7 @@ func inviteExistingUserToOrg(c *models.ReqContext, user *models.User, inviteDto
if inviteDto.SendEmail && util.IsEmail(user.Email) {
emailCmd := models.SendEmailCommand{
To: []string{user.Email},
Template: "invited_to_org.html",
Template: "invited_to_org",
Data: map[string]interface{}{
"Name": user.NameOrFallback(),
"OrgName": c.OrgName,
+2 -2
View File
@@ -11,7 +11,7 @@ type SendEmailAttachFile struct {
Content []byte
}
// SendEmailCommand is command for sending emails
// SendEmailCommand is the command for sending emails
type SendEmailCommand struct {
To []string
SingleEmail bool
@@ -24,7 +24,7 @@ type SendEmailCommand struct {
AttachedFiles []*SendEmailAttachFile
}
// SendEmailCommandSync is command for sending emails in sync
// SendEmailCommandSync is the command for sending emails synchronously
type SendEmailCommandSync struct {
SendEmailCommand
}
+1 -1
View File
@@ -100,7 +100,7 @@ func (en *EmailNotifier) Notify(evalContext *alerting.EvalContext) error {
},
To: en.Addresses,
SingleEmail: en.SingleEmail,
Template: "alert_notification.html",
Template: "alert_notification",
EmbeddedFiles: []string{},
},
}
@@ -96,7 +96,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
},
To: en.Addresses,
SingleEmail: en.SingleEmail,
Template: "ng_alert_notification.html",
Template: "ng_alert_notification",
},
}
@@ -80,7 +80,7 @@ func TestEmailNotifier(t *testing.T) {
"subject": "[FIRING:1] (AlwaysFiring warning)",
"to": []string{"someops@example.com", "somedev@example.com"},
"single_email": false,
"template": "ng_alert_notification.html",
"template": "ng_alert_notification",
"data": map[string]interface{}{
"Title": "[FIRING:1] (AlwaysFiring warning)",
"Message": "[FIRING:1] (AlwaysFiring warning)",
+1 -1
View File
@@ -17,7 +17,7 @@ type Message struct {
SingleEmail bool
From string
Subject string
Body string
Body map[string]string
Info string
ReplyTo []string
EmbeddedFiles []string
+49 -19
View File
@@ -67,18 +67,7 @@ func (ns *NotificationService) dialAndSend(messages ...*Message) (int, error) {
}
for _, msg := range messages {
m := gomail.NewMessage()
m.SetHeader("From", msg.From)
m.SetHeader("To", msg.To...)
m.SetHeader("Subject", msg.Subject)
ns.setFiles(m, msg)
for _, replyTo := range msg.ReplyTo {
m.SetAddressHeader("Reply-To", replyTo, "")
}
m.SetBody("text/html", msg.Body)
m := ns.buildEmail(msg)
innerError := dialer.DialAndSend(m)
emailsSentTotal.Inc()
@@ -100,6 +89,28 @@ func (ns *NotificationService) dialAndSend(messages ...*Message) (int, error) {
return sentEmailsCount, err
}
func (ns *NotificationService) buildEmail(msg *Message) *gomail.Message {
m := gomail.NewMessage()
m.SetHeader("From", msg.From)
m.SetHeader("To", msg.To...)
m.SetHeader("Subject", msg.Subject)
ns.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(ns.Cfg.Smtp.ContentTypes) - 1; i >= 0; i-- {
if i == len(ns.Cfg.Smtp.ContentTypes)-1 {
m.SetBody(ns.Cfg.Smtp.ContentTypes[i], msg.Body[ns.Cfg.Smtp.ContentTypes[i]])
} else {
m.AddAlternative(ns.Cfg.Smtp.ContentTypes[i], msg.Body[ns.Cfg.Smtp.ContentTypes[i]])
}
}
return m
}
// setFiles attaches files in various forms
func (ns *NotificationService) setFiles(
m *gomail.Message,
@@ -169,18 +180,26 @@ func (ns *NotificationService) buildEmailMessage(cmd *models.SendEmailCommand) (
return nil, models.ErrSmtpNotEnabled
}
var buffer bytes.Buffer
var err error
data := cmd.Data
if data == nil {
data = make(map[string]interface{}, 10)
}
setDefaultTemplateData(data, nil)
err = mailTemplates.ExecuteTemplate(&buffer, cmd.Template, data)
if err != nil {
return nil, err
body := make(map[string]string)
for _, contentType := range ns.Cfg.Smtp.ContentTypes {
fileExtension, err := getFileExtensionByContentType(contentType)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
err = mailTemplates.ExecuteTemplate(&buffer, cmd.Template+fileExtension, data)
if err != nil {
return nil, err
}
body[contentType] = buffer.String()
}
subject := cmd.Subject
@@ -213,7 +232,7 @@ func (ns *NotificationService) buildEmailMessage(cmd *models.SendEmailCommand) (
SingleEmail: cmd.SingleEmail,
From: addr.String(),
Subject: subject,
Body: buffer.String(),
Body: body,
EmbeddedFiles: cmd.EmbeddedFiles,
AttachedFiles: buildAttachedFiles(cmd.AttachedFiles),
ReplyTo: cmd.ReplyTo,
@@ -235,3 +254,14 @@ func buildAttachedFiles(
return result
}
func getFileExtensionByContentType(contentType string) (string, error) {
switch contentType {
case "text/html":
return ".html", nil
case "text/plain":
return ".txt", nil
default:
return "", fmt.Errorf("unrecognized content type %q", contentType)
}
}
+41
View File
@@ -0,0 +1,41 @@
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"))
})
}
+9 -7
View File
@@ -19,9 +19,9 @@ import (
)
var mailTemplates *template.Template
var tmplResetPassword = "reset_password.html"
var tmplSignUpStarted = "signup_started.html"
var tmplWelcomeOnSignUp = "welcome_on_signup.html"
var tmplResetPassword = "reset_password"
var tmplSignUpStarted = "signup_started"
var tmplWelcomeOnSignUp = "welcome_on_signup"
func init() {
registry.RegisterService(&NotificationService{})
@@ -56,10 +56,12 @@ func (ns *NotificationService) Init() error {
"Subject": subjectTemplateFunc,
})
templatePattern := filepath.Join(ns.Cfg.StaticRootPath, ns.Cfg.Smtp.TemplatesPattern)
_, err := mailTemplates.ParseGlob(templatePattern)
if err != nil {
return err
for _, pattern := range ns.Cfg.Smtp.TemplatesPatterns {
templatePattern := filepath.Join(ns.Cfg.StaticRootPath, pattern)
_, err := mailTemplates.ParseGlob(templatePattern)
if err != nil {
return err
}
}
if !util.IsEmail(ns.Cfg.Smtp.FromAddress) {
@@ -16,9 +16,10 @@ func TestNotificationService(t *testing.T) {
}
ns.Cfg.StaticRootPath = "../../../public/"
ns.Cfg.Smtp.Enabled = true
ns.Cfg.Smtp.TemplatesPattern = "emails/*.html"
ns.Cfg.Smtp.TemplatesPatterns = []string{"emails/*.html", "emails/*.txt"}
ns.Cfg.Smtp.FromAddress = "from@address.com"
ns.Cfg.Smtp.FromName = "Grafana Admin"
ns.Cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"}
ns.Bus = bus.New()
err := ns.Init()
@@ -29,8 +30,10 @@ func TestNotificationService(t *testing.T) {
require.NoError(t, err)
sentMsg := <-ns.mailQueue
assert.Contains(t, sentMsg.Body, "body")
assert.Contains(t, sentMsg.Body["text/html"], "body")
assert.NotContains(t, sentMsg.Body["text/plain"], "body")
assert.Equal(t, "Reset your Grafana password - asd@asd.com", sentMsg.Subject)
assert.NotContains(t, sentMsg.Body, "Subject")
assert.NotContains(t, sentMsg.Body["text/html"], "Subject")
assert.NotContains(t, sentMsg.Body["text/plain"], "Subject")
})
}
@@ -19,9 +19,10 @@ func TestEmailIntegrationTest(t *testing.T) {
ns.Bus = bus.New()
ns.Cfg = setting.NewCfg()
ns.Cfg.Smtp.Enabled = true
ns.Cfg.Smtp.TemplatesPattern = "emails/*.html"
ns.Cfg.Smtp.TemplatesPatterns = []string{"emails/*.html", "emails/*.txt"}
ns.Cfg.Smtp.FromAddress = "from@address.com"
ns.Cfg.Smtp.FromName = "Grafana Admin"
ns.Cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"}
err := ns.Init()
So(err, ShouldBeNil)
@@ -52,7 +53,7 @@ func TestEmailIntegrationTest(t *testing.T) {
},
},
To: []string{"asdf@asdf.com"},
Template: "alert_notification.html",
Template: "alert_notification",
}
err := ns.sendEmailCommandHandler(cmd)
@@ -61,7 +62,9 @@ func TestEmailIntegrationTest(t *testing.T) {
sentMsg := <-ns.mailQueue
So(sentMsg.From, ShouldEqual, "Grafana Admin <from@address.com>")
So(sentMsg.To[0], ShouldEqual, "asdf@asdf.com")
err = ioutil.WriteFile("../../../tmp/test_email.html", []byte(sentMsg.Body), 0777)
err = ioutil.WriteFile("../../../tmp/test_email.html", []byte(sentMsg.Body["text/html"]), 0777)
So(err, ShouldBeNil)
err = ioutil.WriteFile("../../../tmp/test_email.txt", []byte(sentMsg.Body["text/plain"]), 0777)
So(err, ShouldBeNil)
})
})
+6 -2
View File
@@ -1,5 +1,7 @@
package setting
import "github.com/grafana/grafana/pkg/util"
type SmtpSettings struct {
Enabled bool
Host string
@@ -14,7 +16,8 @@ type SmtpSettings struct {
SkipVerify bool
SendWelcomeEmailOnSignUp bool
TemplatesPattern string
TemplatesPatterns []string
ContentTypes []string
}
func (cfg *Cfg) readSmtpSettings() {
@@ -33,5 +36,6 @@ func (cfg *Cfg) readSmtpSettings() {
emails := cfg.Raw.Section("emails")
cfg.Smtp.SendWelcomeEmailOnSignUp = emails.Key("welcome_email_on_sign_up").MustBool(false)
cfg.Smtp.TemplatesPattern = emails.Key("templates_pattern").MustString("emails/*.html")
cfg.Smtp.TemplatesPatterns = util.SplitString(emails.Key("templates_pattern").MustString("emails/*.html, emails/*.txt"))
cfg.Smtp.ContentTypes = util.SplitString(emails.Key("content_types").MustString("text/html"))
}
@@ -1369,7 +1369,7 @@ var expEmailNotifications = []*models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
To: []string{"test@email.com"},
SingleEmail: true,
Template: "ng_alert_notification.html",
Template: "ng_alert_notification",
Subject: "[FIRING:1] EmailAlert ",
Data: map[string]interface{}{
"Title": "[FIRING:1] EmailAlert ",