feat(email): improve email templating (#27)

This commit is contained in:
oidq
2024-09-16 23:10:08 +02:00
committed by GitHub
parent 1f0ec08290
commit 64cf56276a
14 changed files with 711 additions and 167 deletions

View File

@@ -4,6 +4,7 @@ import (
userAgentParser "github.com/mileusna/useragent"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"log"
)
@@ -55,14 +56,16 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
var user model.User
s.db.Where("id = ?", userID).First(&user)
title := "New device login with " + s.appConfigService.DbConfig.AppName.Value
err := s.emailService.Send(user.Email, title, "login-with-new-device", map[string]interface{}{
"ipAddress": ipAddress,
"device": s.DeviceStringFromUserAgent(userAgent),
"dateTimeString": createdAuditLog.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
Device: s.DeviceStringFromUserAgent(userAgent),
DateTime: createdAuditLog.CreatedAt.UTC(),
})
if err != nil {
log.Printf("Failed to send email: %v\n", err)
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
}

View File

@@ -1,62 +1,90 @@
package service
import (
"bytes"
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
htemplate "html/template"
"io/fs"
"mime/multipart"
"mime/quotedprintable"
"net/smtp"
"os"
"strings"
"net/textproto"
ttemplate "text/template"
)
type EmailService struct {
appConfigService *AppConfigService
htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template
}
func NewEmailService(appConfigService *AppConfigService) *EmailService {
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
return &EmailService{
appConfigService: appConfigService}
appConfigService: appConfigService,
htmlTemplates: htmlTemplates,
textTemplates: textTemplates,
}, nil
}
// Send sends an email notification
func (s *EmailService) Send(toEmail, title, templateName string, templateParameters map[string]interface{}) error {
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if s.appConfigService.DbConfig.EmailEnabled.Value != "true" {
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
// Construct the email message
subject := fmt.Sprintf("Subject: %s\n", title)
subject += "From: " + s.appConfigService.DbConfig.SmtpFrom.Value + "\n"
subject += "To: " + toEmail + "\n"
subject += "Content-Type: text/html; charset=UTF-8\n"
data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
Data: tData,
}
body, err := os.ReadFile(fmt.Sprintf("./email-templates/%s.html", templateName))
bodyString := string(body)
body, boundary, err := prepareBody(srv, template, data)
if err != nil {
return fmt.Errorf("failed to read email template: %w", err)
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
}
// Replace template parameters
templateParameters["appName"] = s.appConfigService.DbConfig.AppName.Value
templateParameters["appUrl"] = common.EnvConfig.AppURL
for key, value := range templateParameters {
bodyString = strings.ReplaceAll(bodyString, fmt.Sprintf("{{%s}}", key), fmt.Sprintf("%v", value))
}
emailBody := []byte(subject + bodyString)
// Construct the email message
c := email.NewComposer()
c.AddHeader("Subject", template.Title(data))
c.AddAddressHeader("From", []email.Address{
{
Email: srv.appConfigService.DbConfig.SmtpFrom.Value,
Name: srv.appConfigService.DbConfig.AppName.Value,
},
})
c.AddAddressHeader("To", []email.Address{toEmail})
c.AddHeaderRaw("Content-Type",
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
)
c.Body(body)
// Set up the authentication information.
auth := smtp.PlainAuth("", s.appConfigService.DbConfig.SmtpUser.Value, s.appConfigService.DbConfig.SmtpPassword.Value, s.appConfigService.DbConfig.SmtpHost.Value)
auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value,
)
// Send the email
err = smtp.SendMail(
s.appConfigService.DbConfig.SmtpHost.Value+":"+s.appConfigService.DbConfig.SmtpPort.Value,
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
auth,
s.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail},
emailBody,
srv.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail.Email},
[]byte(c.String()),
)
if err != nil {
@@ -65,3 +93,45 @@ func (s *EmailService) Send(toEmail, title, templateName string, templateParamet
return nil
}
func prepareBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (string, string, error) {
body := bytes.NewBuffer(nil)
mpart := multipart.NewWriter(body)
// prepare text part
var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader)
if err != nil {
return "", "", fmt.Errorf("create text part: %w", err)
}
textQp := quotedprintable.NewWriter(textPart)
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textQp, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err)
}
// prepare html part
var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil {
return "", "", fmt.Errorf("create html part: %w", err)
}
htmlQp := quotedprintable.NewWriter(htmlPart)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err)
}
err = mpart.Close()
if err != nil {
return "", "", fmt.Errorf("close multipart: %w", err)
}
return body.String(), mpart.Boundary(), nil
}

View File

@@ -0,0 +1,37 @@
package service
import (
"fmt"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"time"
)
/**
How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end
Notes:
- backend app must be restarted to reread all the template files
- root "." object in templates is `email.TemplateData`
- xxxxTemplateData structure is visible under .Data in templates
*/
var NewLoginTemplate = email.Template[NewLoginTemplateData]{
Path: "login-with-new-device",
Title: func(data *email.TemplateData[NewLoginTemplateData]) string {
return fmt.Sprintf("New device login with %s", data.AppName)
},
}
type NewLoginTemplateData struct {
IPAddress string
Device string
DateTime time.Time
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path}