Skip to content

Commit

Permalink
feat: introduce html mails
Browse files Browse the repository at this point in the history
wip
  • Loading branch information
bjoern-m committed Feb 7, 2025
1 parent e2d0db2 commit 085da5d
Show file tree
Hide file tree
Showing 18 changed files with 143 additions and 29 deletions.
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
templatePlane *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.templatePlane = 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.templatePlane.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
8 changes: 4 additions & 4 deletions backend/mail/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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}}
34 changes: 34 additions & 0 deletions backend/mail/templates/layout.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{{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: 0; 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: 20px; box-sizing: border-box;">
<!-- Header -->
<tr style="height: 80px;">
<td valign="top">{{.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}}
File renamed without changes.
7 changes: 7 additions & 0 deletions backend/mail/templates/recovery.html.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{define "content"}}
{{t "recovery_text" .}}

{{ template "code" .Code }}

{{t "ttl_text" .}}
{{end}}
File renamed without changes.

0 comments on commit 085da5d

Please sign in to comment.