From e73c0d5d884a5ebcef72054f4089cd36b7988fcd Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 14 Oct 2024 19:33:42 +0200 Subject: [PATCH] feat: add mail header support via `GOTRUE_SMTP_HEADERS` with `$messageType` support --- internal/conf/configuration.go | 28 ++++++++++++++++++-- internal/mailer/mailme.go | 9 ++++++- internal/mailer/noop.go | 2 +- internal/mailer/template.go | 44 +++++++++++++++++++++++++++++++- internal/mailer/template_test.go | 30 ++++++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 internal/mailer/template_test.go diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 8c7dcae1ab..5ea9c71fea 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -3,6 +3,7 @@ package conf import ( "bytes" "encoding/base64" + "encoding/json" "errors" "fmt" "net/url" @@ -348,16 +349,39 @@ type SMTPConfiguration struct { Pass string `json:"pass,omitempty"` AdminEmail string `json:"admin_email" split_words:"true"` SenderName string `json:"sender_name" split_words:"true"` + Headers string `json:"headers"` + + fromAddress string `json:"-"` + normalizedHeaders map[string][]string `json:"-"` } func (c *SMTPConfiguration) Validate() error { + headers := make(map[string][]string) + + if c.Headers != "" { + err := json.Unmarshal([]byte(c.Headers), &headers) + if err != nil { + return fmt.Errorf("conf: SMTP headers not a map[string][]string format: %w", err) + } + } + + if len(headers) > 0 { + c.normalizedHeaders = headers + } + + mail := gomail.NewMessage() + + c.fromAddress = mail.FormatAddress(c.AdminEmail, c.SenderName) + return nil } func (c *SMTPConfiguration) FromAddress() string { - mail := gomail.NewMessage() + return c.fromAddress +} - return mail.FormatAddress(c.AdminEmail, c.SenderName) +func (c *SMTPConfiguration) NormalizedHeaders() map[string][]string { + return c.normalizedHeaders } type MailerConfiguration struct { diff --git a/internal/mailer/mailme.go b/internal/mailer/mailme.go index 26f0845221..bd659be7d3 100644 --- a/internal/mailer/mailme.go +++ b/internal/mailer/mailme.go @@ -38,7 +38,7 @@ type MailmeMailer struct { // Mail sends a templated mail. It will try to load the template from a URL, and // otherwise fall back to the default -func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}) error { +func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}, headers map[string][]string) error { if m.FuncMap == nil { m.FuncMap = map[string]interface{}{} } @@ -69,6 +69,13 @@ func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate st mail.SetHeader("From", m.From) mail.SetHeader("To", to) mail.SetHeader("Subject", subject.String()) + + for k, v := range headers { + if v != nil { + mail.SetHeader(k, v...) + } + } + mail.SetBody("text/html", body) dial := gomail.NewDialer(m.Host, m.Port, m.User, m.Pass) diff --git a/internal/mailer/noop.go b/internal/mailer/noop.go index 203c19aff8..ed44d7b32e 100644 --- a/internal/mailer/noop.go +++ b/internal/mailer/noop.go @@ -6,7 +6,7 @@ import ( type noopMailClient struct{} -func (m *noopMailClient) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}) error { +func (m *noopMailClient) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}, headers map[string][]string) error { if to == "" { return errors.New("to field cannot be empty") } diff --git a/internal/mailer/template.go b/internal/mailer/template.go index cd1aacf95e..e66858f8a7 100644 --- a/internal/mailer/template.go +++ b/internal/mailer/template.go @@ -12,7 +12,7 @@ import ( ) type MailClient interface { - Mail(string, string, string, string, map[string]interface{}) error + Mail(string, string, string, string, map[string]interface{}, map[string][]string) error } // TemplateMailer will send mail and use templates from the site for easy mail styling @@ -87,6 +87,41 @@ func (m TemplateMailer) ValidateEmail(email string) error { return checkmail.ValidateFormat(email) } +func (m *TemplateMailer) Headers(messageType string) map[string][]string { + originalHeaders := m.Config.SMTP.NormalizedHeaders() + + if originalHeaders == nil { + return nil + } + + headers := make(map[string][]string, len(originalHeaders)) + + for header, values := range originalHeaders { + replacedValues := make([]string, 0, len(values)) + + if header == "" { + continue + } + + for _, value := range values { + if value == "" { + continue + } + + // TODO: in the future, use a templating engine to add more contextual data available to headers + if strings.Contains(value, "$messageType") { + replacedValues = append(replacedValues, strings.ReplaceAll(value, "$messageType", messageType)) + } else { + replacedValues = append(replacedValues, value) + } + } + + headers[header] = replacedValues + } + + return headers +} + // InviteMail sends a invite mail to a new user func (m *TemplateMailer) InviteMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { path, err := getPath(m.Config.Mailer.URLPaths.Invite, &EmailParams{ @@ -115,6 +150,7 @@ func (m *TemplateMailer) InviteMail(r *http.Request, user *models.User, otp, ref m.Config.Mailer.Templates.Invite, defaultInviteMail, data, + m.Headers("invite"), ) } @@ -145,6 +181,7 @@ func (m *TemplateMailer) ConfirmationMail(r *http.Request, user *models.User, ot m.Config.Mailer.Templates.Confirmation, defaultConfirmationMail, data, + m.Headers("confirm"), ) } @@ -163,6 +200,7 @@ func (m *TemplateMailer) ReauthenticateMail(r *http.Request, user *models.User, m.Config.Mailer.Templates.Reauthentication, defaultReauthenticateMail, data, + m.Headers("reauthenticate"), ) } @@ -227,6 +265,7 @@ func (m *TemplateMailer) EmailChangeMail(r *http.Request, user *models.User, otp template, defaultEmailChangeMail, data, + m.Headers("email_change"), ) }(email.Address, email.Otp, email.TokenHash, email.Template) } @@ -267,6 +306,7 @@ func (m *TemplateMailer) RecoveryMail(r *http.Request, user *models.User, otp, r m.Config.Mailer.Templates.Recovery, defaultRecoveryMail, data, + m.Headers("recovery"), ) } @@ -297,6 +337,7 @@ func (m *TemplateMailer) MagicLinkMail(r *http.Request, user *models.User, otp, m.Config.Mailer.Templates.MagicLink, defaultMagicLinkMail, data, + m.Headers("magiclink"), ) } @@ -308,6 +349,7 @@ func (m TemplateMailer) Send(user *models.User, subject, body string, data map[s "", body, data, + m.Headers("other"), ) } diff --git a/internal/mailer/template_test.go b/internal/mailer/template_test.go new file mode 100644 index 0000000000..782feac2ce --- /dev/null +++ b/internal/mailer/template_test.go @@ -0,0 +1,30 @@ +package mailer + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestTemplateHeaders(t *testing.T) { + mailer := TemplateMailer{ + Config: &conf.GlobalConfiguration{ + SMTP: conf.SMTPConfiguration{ + Headers: `{"X-Test-A": ["test-a", "test-b"], "X-Test-B": ["test-c", "abc $messageType"]}`, + }, + }, + } + + require.NoError(t, mailer.Config.SMTP.Validate()) + + require.Equal(t, mailer.Headers("TEST-MESSAGE-TYPE"), map[string][]string{ + "X-Test-A": {"test-a", "test-b"}, + "X-Test-B": {"test-c", "abc TEST-MESSAGE-TYPE"}, + }) + + require.Equal(t, mailer.Headers("OTHER-TYPE"), map[string][]string{ + "X-Test-A": {"test-a", "test-b"}, + "X-Test-B": {"test-c", "abc OTHER-TYPE"}, + }) +}