Skip to content

Commit

Permalink
feat: add mail header support via GOTRUE_SMTP_HEADERS with `$messag…
Browse files Browse the repository at this point in the history
…eType` (#1804)

Adds support for additional and configurable email message headers. Set
the `GOTRUE_SMTP_HEADERS` value to a JSON object of the shape
(TypeScript notation)

```
{ [header: string]: string[] }
```

Use the special string `$messageType` in the value portion to identify
the message type being sent (for now: `invite`, `confirm`,
`reauthenticate`, `email_change`, `recovery`, `magiclink`, `other`).

To use this with [AWS SES
Feedback-ID](https://aws.amazon.com/about-aws/whats-new/2024/06/amazon-ses-custom-values-feedback-header/)
one way to do it would be to [set SES SMTP message
tags](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-send-email.html#event-publishing-using-ses-headers):

```
GOTRUE_SMTP_HEADERS={"x-ses-message-tags": ["ses:feedback-id-a=<project-ref>,ses:feedback-id-b=$messageType"]}
```
  • Loading branch information
hf authored Oct 14, 2024
1 parent 3af03be commit 99d6a13
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 5 deletions.
28 changes: 26 additions & 2 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package conf
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion internal/mailer/mailme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{}
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/mailer/noop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
44 changes: 43 additions & 1 deletion internal/mailer/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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"),
)
}

Expand Down Expand Up @@ -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"),
)
}

Expand All @@ -163,6 +200,7 @@ func (m *TemplateMailer) ReauthenticateMail(r *http.Request, user *models.User,
m.Config.Mailer.Templates.Reauthentication,
defaultReauthenticateMail,
data,
m.Headers("reauthenticate"),
)
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"),
)
}

Expand Down Expand Up @@ -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"),
)
}

Expand All @@ -308,6 +349,7 @@ func (m TemplateMailer) Send(user *models.User, subject, body string, data map[s
"",
body,
data,
m.Headers("other"),
)
}

Expand Down
30 changes: 30 additions & 0 deletions internal/mailer/template_test.go
Original file line number Diff line number Diff line change
@@ -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"},
})
}

0 comments on commit 99d6a13

Please sign in to comment.