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)
+ }
+}