Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce html mails #2045

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ func (a ReSendPasscode) Execute(c flowpilot.ExecutionContext) error {

webhookData := webhook.EmailSend{
Subject: passcodeResult.Subject,
BodyPlain: passcodeResult.Body,
BodyPlain: passcodeResult.BodyPlain,
Body: passcodeResult.BodyHTML,
ToEmailAddress: sendParams.EmailAddress,
DeliveredByHanko: deps.Cfg.EmailDelivery.Enabled,
AcceptLanguage: sendParams.Language,
Expand Down
3 changes: 2 additions & 1 deletion backend/flow_api/flow/credential_usage/hook_send_passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ func (h SendPasscode) Execute(c flowpilot.HookExecutionContext) error {

webhookData := webhook.EmailSend{
Subject: passcodeResult.Subject,
BodyPlain: passcodeResult.Body,
Body: passcodeResult.BodyHTML,
BodyPlain: passcodeResult.BodyPlain,
ToEmailAddress: sendParams.EmailAddress,
DeliveredByHanko: deps.Cfg.EmailDelivery.Enabled,
AcceptLanguage: sendParams.Language,
Expand Down
13 changes: 9 additions & 4 deletions backend/flow_api/services/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ func NewEmailService(cfg config.Config) (*Email, error) {
}

// SendEmail sends an email to the emailAddress with the given subject and body.
func (s *Email) SendEmail(emailAddress, subject, body string) error {
func (s *Email) SendEmail(emailAddress, subject, body, htmlBody string) error {
message := gomail.NewMessage()
message.SetAddressHeader("To", emailAddress, "")
message.SetAddressHeader("From", s.cfg.EmailDelivery.FromAddress, s.cfg.EmailDelivery.FromName)
message.SetHeader("Subject", subject)
message.SetBody("text/plain", body)
message.AddAlternative("text/html", htmlBody)

if err := s.mailer.Send(message); err != nil {
return err
Expand All @@ -50,9 +51,13 @@ func (s *Email) RenderSubject(lang, template string, data map[string]interface{}
return s.renderer.Translate(lang, fmt.Sprintf("subject_%s", template), data)
}

// RenderBody renders the body with the given template. The template name must be the name of the template without the
// RenderBodyPlain renders the body with the given template. The template name must be the name of the template without the
// content type and the file ending. E.g. when the file is created as "email_verification_text.tmpl" then the template
// name is just "email_verification"
func (s *Email) RenderBody(lang, template string, data map[string]interface{}) (string, error) {
return s.renderer.Render(fmt.Sprintf("%s_text.tmpl", template), lang, data)
func (s *Email) RenderBodyPlain(lang, template string, data map[string]interface{}) (string, error) {
return s.renderer.RenderPlain(template, lang, data)
}

func (s *Email) RenderBodyHTML(lang, template string, data map[string]interface{}) (string, error) {
return s.renderer.RenderHTML(template, lang, data)
}
29 changes: 22 additions & 7 deletions backend/flow_api/services/passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ type ValidatePasscodeParams struct {
type SendPasscodeResult struct {
PasscodeModel models.Passcode
Subject string
Body string
BodyPlain string
BodyHTML string
Code string
}

Expand Down Expand Up @@ -141,20 +142,33 @@ func (s *passcode) SendPasscode(tx *pop.Connection, p SendPasscodeParams) (*Send
}

durationTTL := time.Duration(passcodeModel.Ttl) * time.Second
data := map[string]interface{}{

subjectData := map[string]interface{}{
"Code": code,
"TTL": fmt.Sprintf("%.0f", durationTTL.Minutes()),
}

subject := s.emailService.RenderSubject(p.Language, p.Template, subjectData)

bodyData := map[string]interface{}{
"Code": code,
"ServiceName": s.cfg.Service.Name,
"TTL": fmt.Sprintf("%.0f", durationTTL.Minutes()),
"ServiceName": s.cfg.Service.Name,
"Subject": subject,
}

bodyPlain, err := s.emailService.RenderBodyPlain(p.Language, p.Template, bodyData)
if err != nil {
return nil, err
}

subject := s.emailService.RenderSubject(p.Language, p.Template, data)
body, err := s.emailService.RenderBody(p.Language, p.Template, data)
bodyHTML, err := s.emailService.RenderBodyHTML(p.Language, p.Template, bodyData)
if err != nil {
return nil, err
}

if s.cfg.EmailDelivery.Enabled {
err = s.emailService.SendEmail(p.EmailAddress, subject, body)
err = s.emailService.SendEmail(p.EmailAddress, subject, bodyPlain, bodyHTML)
if err != nil {
return nil, err
}
Expand All @@ -163,7 +177,8 @@ func (s *passcode) SendPasscode(tx *pop.Connection, p SendPasscodeParams) (*Send
return &SendPasscodeResult{
PasscodeModel: passcodeModel,
Subject: subject,
Body: body,
BodyPlain: bodyPlain,
BodyHTML: bodyHTML,
Code: code,
}, nil
}
Expand Down
14 changes: 11 additions & 3 deletions backend/handler/passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,22 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
lang := c.Request().Header.Get("Accept-Language")

subject := h.renderer.Translate(lang, "email_subject_login", data)
data["Subject"] = subject

body, err := h.renderer.Render("login_text.tmpl", lang, data)
bodyPlain, err := h.renderer.RenderPlain("login", lang, data)
if err != nil {
return fmt.Errorf("failed to render email template: %w", err)
}

bodyHTML, err := h.renderer.RenderHTML("login", lang, data)
if err != nil {
return fmt.Errorf("failed to render email template: %w", err)
}

webhookData := webhook.EmailSend{
Subject: subject,
BodyPlain: body,
BodyPlain: bodyPlain,
Body: bodyHTML,
ToEmailAddress: email.Address,
DeliveredByHanko: true,
AcceptLanguage: lang,
Expand All @@ -223,7 +230,8 @@ func (h *PasscodeHandler) Init(c echo.Context) error {

message.SetHeader("Subject", subject)

message.SetBody("text/plain", body)
message.SetBody("text/plain", bodyPlain)
message.AddAlternative("text/html", bodyHTML)

err = h.mailer.Send(message)
if err != nil {
Expand Down
41 changes: 32 additions & 9 deletions backend/mail/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import (
var mailFS embed.FS

type Renderer struct {
template *template.Template
bundle *i18n.Bundle
localizer *i18n.Localizer
templatePlain *template.Template
bundle *i18n.Bundle
localizer *i18n.Localizer
}

// NewRenderer creates an instance of Renderer, which renders the templates (located in mail/templates) with locales (located in mail/locales)
Expand All @@ -37,11 +37,11 @@ func NewRenderer() (*Renderer, error) {
// add the translate function to the template, so it can be used inside
funcMap := template.FuncMap{"t": r.translate}
t := template.New("root").Funcs(funcMap)
_, err = t.ParseFS(mailFS, "templates/*")
_, err = t.ParseFS(mailFS, "templates/*.txt.tmpl")
if err != nil {
return nil, fmt.Errorf("failed to load templates: %w", err)
}
r.template = t
r.templatePlain = t

return r, nil
}
Expand All @@ -55,19 +55,42 @@ func (r *Renderer) translate(messageID string, templateData map[string]interface
})
}

// Render renders a template with the given data and lang.
// RenderPlain renders a template with the given data and lang.
// The lang can be the contents of Accept-Language headers as defined in http://www.ietf.org/rfc/rfc2616.txt.
func (r *Renderer) Render(templateName string, lang string, data map[string]interface{}) (string, error) {
func (r *Renderer) RenderPlain(templateName string, lang string, data map[string]interface{}) (string, error) {
r.localizer = i18n.NewLocalizer(r.bundle, lang) // set the localizer, so the test will be translated to the given language
data["renderer_lang"] = lang
templateBuffer := &bytes.Buffer{}
err := r.template.ExecuteTemplate(templateBuffer, templateName, data)
err := r.templatePlain.ExecuteTemplate(templateBuffer, fmt.Sprintf("%s.txt.tmpl", templateName), data)
if err != nil {
return "", fmt.Errorf("failed to fill template with data: %w", err)
return "", fmt.Errorf("failed to fill plain text template with data: %w", err)
}
return strings.TrimSpace(templateBuffer.String()), nil
}

// RenderHTML renders an HTML template with the given data and lang.
// The lang can be the contents of Accept-Language headers as defined in http://www.ietf.org/rfc/rfc2616.txt.
func (r *Renderer) RenderHTML(templateName string, lang string, data map[string]interface{}) (string, error) {
var buffer bytes.Buffer

r.localizer = i18n.NewLocalizer(r.bundle, lang)
data["renderer_lang"] = lang

templateHTML := template.New("root").Funcs(template.FuncMap{"t": r.translate})
patterns := []string{"templates/layout.html.tmpl", fmt.Sprintf("templates/%s.html.tmpl", templateName)}
_, err := templateHTML.ParseFS(mailFS, patterns...)
if err != nil {
return "", fmt.Errorf("failed to parse html template: %w", err)
}

err = templateHTML.ExecuteTemplate(&buffer, "layout", data)
if err != nil {
return "", fmt.Errorf("failed to execute html template: %w", err)
}

return strings.TrimSpace(buffer.String()), nil
}

func (r *Renderer) Translate(lang string, messageID string, data map[string]interface{}) string {
loc := i18n.NewLocalizer(r.bundle, lang)
return loc.MustLocalize(&i18n.LocalizeConfig{
Expand Down
74 changes: 69 additions & 5 deletions backend/mail/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestNewRenderer(t *testing.T) {
assert.NotEmpty(t, renderer)
}

func TestRenderer_Render(t *testing.T) {
func TestRenderer_RenderPlain(t *testing.T) {
renderer, err := NewRenderer()

assert.NoError(t, err)
Expand All @@ -32,7 +32,7 @@ func TestRenderer_Render(t *testing.T) {
}{
{
Name: "Login text template",
Template: "login_text.tmpl",
Template: "login",
Lang: "en",
Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.",
WantErr: false,
Expand All @@ -46,14 +46,14 @@ func TestRenderer_Render(t *testing.T) {
},
{
Name: "Login text template with unknown language",
Template: "login_text.tmpl",
Template: "login",
Lang: "xxx",
Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.",
WantErr: false,
},
{
Name: "Login text template without translations for language",
Template: "login_text.tmpl",
Template: "login",
Lang: "es",
Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.",
WantErr: false,
Expand All @@ -62,7 +62,7 @@ func TestRenderer_Render(t *testing.T) {

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := renderer.Render(test.Template, test.Lang, templateData)
result, err := renderer.RenderPlain(test.Template, test.Lang, templateData)

if test.WantErr {
assert.Error(t, err)
Expand All @@ -74,6 +74,70 @@ func TestRenderer_Render(t *testing.T) {
}
}

func TestRenderer_RenderHTML(t *testing.T) {
renderer, err := NewRenderer()

assert.NoError(t, err)
assert.NotEmpty(t, renderer)

templateData := map[string]interface{}{
"TTL": 5,
"Code": "123456",
}

tests := []struct {
Name string
Template string
Lang string
Expected []string
WantErr bool
}{
{
Name: "Login text template",
Template: "login",
Lang: "en",
Expected: []string{"<!DOCTYPE html>", "Enter the following passcode to verify your identity:", "123456", "The passcode is valid for 5 minutes."},
WantErr: false,
},
{
Name: "Not existing template",
Template: "NotExistingTemplate",
Lang: "en",
Expected: []string{"<!DOCTYPE html>"},
WantErr: true,
},
{
Name: "Login text template with unknown language",
Template: "login",
Lang: "xxx",
Expected: []string{"<!DOCTYPE html>", "Enter the following passcode to verify your identity:", "123456", "The passcode is valid for 5 minutes."},
WantErr: false,
},
{
Name: "Login text template without translations for language",
Template: "login",
Lang: "es",
Expected: []string{"<!DOCTYPE html>", "Enter the following passcode to verify your identity:", "123456", "The passcode is valid for 5 minutes."},
WantErr: false,
},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := renderer.RenderHTML(test.Template, test.Lang, templateData)

if test.WantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
for _, expected := range test.Expected {
assert.Contains(t, result, expected)
}
}
})
}
}

func TestRenderer_Translate(t *testing.T) {
renderer, err := NewRenderer()

Expand Down
3 changes: 3 additions & 0 deletions backend/mail/templates/email_login_attempted.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{define "content"}}
{{t "email_login_attempted_text" .}}
{{end}}
3 changes: 3 additions & 0 deletions backend/mail/templates/email_registration_attempted.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{define "content"}}
{{t "email_registration_attempted_text" .}}
{{end}}
7 changes: 7 additions & 0 deletions backend/mail/templates/email_verification.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{define "content"}}
{{t "email_verification_text" .}}

{{template "code" .Code}}

{{t "ttl_text" .}}
{{end}}
33 changes: 33 additions & 0 deletions backend/mail/templates/layout.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{{define "layout"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Subject}}</title>
</head>
<body style="margin: 20px; padding: 0; font-size: 12px; font-family: Arial, sans-serif; font-weight: 400; background-color: #f4f4f4;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 460px; background-color: #ffffff; padding: 20px; margin: 0; box-sizing: border-box;">
<!-- Header -->
<tr>
<td valign="top" height="80">{{.ServiceName}}</td>
</tr>
<!-- Content -->
<tr>
<td>{{template "content" .}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

{{define "code"}}
<div style="font-size: 34px; font-weight: 500; letter-spacing: 3.4px; margin: 20px 0 20px 0;">{{.}}</div>
{{end}}
7 changes: 7 additions & 0 deletions backend/mail/templates/login.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{define "content"}}
{{t "login_text" .}}

{{template "code" .Code}}

{{t "ttl_text" .}}
{{end}}
Loading
Loading