diff --git a/conf/defaults.ini b/conf/defaults.ini index 7d2c9686eee..6bb25d0f65a 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -938,6 +938,9 @@ from_name = Grafana ehlo_identity = startTLS_policy = +[smtp.static_headers] +# Include custom static headers in all outgoing emails + [emails] welcome_email_on_sign_up = false templates_pattern = emails/*.html, emails/*.txt diff --git a/conf/sample.ini b/conf/sample.ini index 74f806e7bc2..4864f270ce9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -891,6 +891,11 @@ # SMTP startTLS policy (defaults to 'OpportunisticStartTLS') ;startTLS_policy = NoStartTLS +[smtp.static_headers] +# Include custom static headers in all outgoing emails +;Foo-Header = bar +;Foo = bar + [emails] ;welcome_email_on_sign_up = false ;templates_pattern = emails/*.html, emails/*.txt diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1d648c0b2c1..9b3ceba46c1 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1283,6 +1283,13 @@ Either "OpportunisticStartTLS", "MandatoryStartTLS", "NoStartTLS". Default is `e
+## [smtp.static_headers] + +Enter key-value pairs on their own lines to be included as headers on outgoing emails. All keys must be in canonical mail header format. +Examples: `Foo=bar`, `Foo-Header=bar`. + +
+ ## [emails] ### welcome_email_on_sign_up diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go index c012a11d5c0..d53b5780237 100644 --- a/pkg/services/notifications/smtp.go +++ b/pkg/services/notifications/smtp.go @@ -62,6 +62,10 @@ func (sc *SmtpClient) Send(messages ...*Message) (int, error) { // buildEmail converts the Message DTO to a gomail message. func (sc *SmtpClient) buildEmail(msg *Message) *gomail.Message { m := gomail.NewMessage() + // add all static headers to the email message + for h, val := range sc.cfg.StaticHeaders { + m.SetHeader(h, val) + } m.SetHeader("From", msg.From) m.SetHeader("To", msg.To...) m.SetHeader("Subject", msg.Subject) diff --git a/pkg/services/notifications/smtp_test.go b/pkg/services/notifications/smtp_test.go index fe2edd6cc6b..6d81061b15a 100644 --- a/pkg/services/notifications/smtp_test.go +++ b/pkg/services/notifications/smtp_test.go @@ -14,6 +14,7 @@ import ( func TestBuildMail(t *testing.T) { cfg := setting.NewCfg() cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} + cfg.Smtp.StaticHeaders = map[string]string{"Foo-Header": "foo_value", "From": "malicious_value"} sc, err := NewSmtpClient(cfg.Smtp) require.NoError(t, err) @@ -29,13 +30,17 @@ func TestBuildMail(t *testing.T) { ReplyTo: []string{"from@address.com"}, } - t.Run("When building email", func(t *testing.T) { + t.Run("Can successfully build mail", func(t *testing.T) { email := sc.buildEmail(message) + staticHeader := email.GetHeader("Foo-Header")[0] + assert.Equal(t, staticHeader, "foo_value") buf := new(bytes.Buffer) _, err := email.WriteTo(buf) require.NoError(t, err) + assert.Contains(t, buf.String(), "Foo-Header: foo_value") + assert.Contains(t, buf.String(), "From: from@address.com") 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")) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 91621b4af2e..e142dc77709 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -1026,6 +1026,7 @@ func (cfg *Cfg) validateStaticRootPath() error { return nil } +// nolint:gocyclo func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.setHomePath(args) @@ -1197,7 +1198,9 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.handleAWSConfig() cfg.readAzureSettings() cfg.readSessionConfig() - cfg.readSmtpSettings() + if err := cfg.readSmtpSettings(); err != nil { + return err + } if err := cfg.readAnnotationSettings(); err != nil { return err } diff --git a/pkg/setting/setting_smtp.go b/pkg/setting/setting_smtp.go index a2a9a11d27a..522b02b0cd4 100644 --- a/pkg/setting/setting_smtp.go +++ b/pkg/setting/setting_smtp.go @@ -1,6 +1,11 @@ package setting -import "github.com/grafana/grafana/pkg/util" +import ( + "fmt" + "regexp" + + "github.com/grafana/grafana/pkg/util" +) type SmtpSettings struct { Enabled bool @@ -14,13 +19,17 @@ type SmtpSettings struct { EhloIdentity string StartTLSPolicy string SkipVerify bool + StaticHeaders map[string]string SendWelcomeEmailOnSignUp bool TemplatesPatterns []string ContentTypes []string } -func (cfg *Cfg) readSmtpSettings() { +// validates mail headers +var mailHeaderRegex = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*(-[A-Z][A-Za-z0-9]*)*$`) + +func (cfg *Cfg) readSmtpSettings() error { sec := cfg.Raw.Section("smtp") cfg.Smtp.Enabled = sec.Key("enabled").MustBool(false) cfg.Smtp.Host = sec.Key("host").String() @@ -38,4 +47,31 @@ func (cfg *Cfg) readSmtpSettings() { cfg.Smtp.SendWelcomeEmailOnSignUp = emails.Key("welcome_email_on_sign_up").MustBool(false) 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")) + + // populate static headers + if err := cfg.readGrafanaSmtpStaticHeaders(); err != nil { + return err + } + + return nil +} + +func validHeader(header string) bool { + return mailHeaderRegex.MatchString(header) +} + +func (cfg *Cfg) readGrafanaSmtpStaticHeaders() error { + staticHeadersSection := cfg.Raw.Section("smtp.static_headers") + keys := staticHeadersSection.Keys() + cfg.Smtp.StaticHeaders = make(map[string]string, len(keys)) + + for _, key := range keys { + if !validHeader(key.Name()) { + return fmt.Errorf("header %q in [smtp.static_headers] configuration: must follow canonical MIME form", key.Name()) + } + + cfg.Smtp.StaticHeaders[key.Name()] = key.Value() + } + + return nil } diff --git a/pkg/setting/setting_smtp_test.go b/pkg/setting/setting_smtp_test.go new file mode 100644 index 00000000000..bdc6f3d03d6 --- /dev/null +++ b/pkg/setting/setting_smtp_test.go @@ -0,0 +1,93 @@ +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" +) + +func TestLoadSmtpStaticHeaders(t *testing.T) { + t.Run("will load valid headers", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() + s, err := f.NewSection("smtp.static_headers") + require.NoError(t, err) + cfg.Raw = f + _, err = s.NewKey("Foo-Header", "foo_val") + require.NoError(t, err) + _, err = s.NewKey("Bar", "bar_val") + require.NoError(t, err) + + err = cfg.readGrafanaSmtpStaticHeaders() + require.NoError(t, err) + + assert.Equal(t, "foo_val", cfg.Smtp.StaticHeaders["Foo-Header"]) + assert.Equal(t, "bar_val", cfg.Smtp.StaticHeaders["Bar"]) + }) + + t.Run("will load no static headers into smtp config when section is defined but has no keys", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() + _, err := f.NewSection("smtp.static_headers") + require.NoError(t, err) + cfg.Raw = f + + err = cfg.readGrafanaSmtpStaticHeaders() + require.NoError(t, err) + + assert.Empty(t, cfg.Smtp.StaticHeaders) + }) + + t.Run("will load no static headers into smtp config when section is not defined", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() + cfg.Raw = f + + err := cfg.readGrafanaSmtpStaticHeaders() + require.NoError(t, err) + + assert.Empty(t, cfg.Smtp.StaticHeaders) + }) + + t.Run("will return error when header label is not in valid format", func(t *testing.T) { + f := ini.Empty() + cfg := NewCfg() + s, err := f.NewSection("smtp.static_headers") + require.NoError(t, err) + _, err = s.NewKey("header with spaces", "value") + require.NoError(t, err) + cfg.Raw = f + + err = cfg.readGrafanaSmtpStaticHeaders() + require.Error(t, err) + }) +} + +func TestSmtpHeaderValidation(t *testing.T) { + testCases := []struct { + input string + expected bool + }{ + //valid + {"Foo", true}, + {"Foo-Bar", true}, + {"Foo123-Bar123", true}, + + //invalid + {"foo", false}, + {"Foo Bar", false}, + {"123Foo", false}, + {"Foo.Bar", false}, + {"foo-bar", false}, + {"foo-Bar", false}, + {"Foo-bar", false}, + {"-Bar", false}, + {"Foo--", false}, + } + + for _, tc := range testCases { + assert.Equal(t, validHeader(tc.input), tc.expected) + } +}