From 64cf56276a07169bc601a11be905c1eea67c4750 Mon Sep 17 00:00:00 2001 From: oidq Date: Mon, 16 Sep 2024 23:10:08 +0200 Subject: [PATCH] feat(email): improve email templating (#27) --- .../components/email_html.tmpl | 14 ++ .../components/email_text.tmpl | 7 + .../components/style_html.tmpl | 80 +++++++ .../login-with-new-device.html | 119 ---------- .../login-with-new-device_html.tmpl | 30 +++ .../login-with-new-device_text.tmpl | 12 + .../internal/bootstrap/router_bootstrap.go | 8 +- backend/internal/common/env_config.go | 26 ++- backend/internal/service/audit_log_service.go | 15 +- backend/internal/service/email_service.go | 128 ++++++++--- .../service/email_service_templates.go | 37 +++ backend/internal/utils/email/composer.go | 213 ++++++++++++++++++ backend/internal/utils/email/composer_test.go | 92 ++++++++ .../utils/email/email_service_templates.go | 97 ++++++++ 14 files changed, 711 insertions(+), 167 deletions(-) create mode 100644 backend/email-templates/components/email_html.tmpl create mode 100644 backend/email-templates/components/email_text.tmpl create mode 100644 backend/email-templates/components/style_html.tmpl delete mode 100644 backend/email-templates/login-with-new-device.html create mode 100644 backend/email-templates/login-with-new-device_html.tmpl create mode 100644 backend/email-templates/login-with-new-device_text.tmpl create mode 100644 backend/internal/service/email_service_templates.go create mode 100644 backend/internal/utils/email/composer.go create mode 100644 backend/internal/utils/email/composer_test.go create mode 100644 backend/internal/utils/email/email_service_templates.go diff --git a/backend/email-templates/components/email_html.tmpl b/backend/email-templates/components/email_html.tmpl new file mode 100644 index 0000000..8e135f6 --- /dev/null +++ b/backend/email-templates/components/email_html.tmpl @@ -0,0 +1,14 @@ +{{ define "root" }} + + + + + {{ template "style" . }} + + +
+ {{ template "base" . }} +
+ + +{{ end }} diff --git a/backend/email-templates/components/email_text.tmpl b/backend/email-templates/components/email_text.tmpl new file mode 100644 index 0000000..d862f02 --- /dev/null +++ b/backend/email-templates/components/email_text.tmpl @@ -0,0 +1,7 @@ +{{- define "root" -}} +{{- template "base" . -}} +{{- end }} + + +-- +This is automatically sent email from {{.AppName}}. diff --git a/backend/email-templates/components/style_html.tmpl b/backend/email-templates/components/style_html.tmpl new file mode 100644 index 0000000..d378806 --- /dev/null +++ b/backend/email-templates/components/style_html.tmpl @@ -0,0 +1,80 @@ +{{ define "style" }} + +{{ end }} diff --git a/backend/email-templates/login-with-new-device.html b/backend/email-templates/login-with-new-device.html deleted file mode 100644 index be251d4..0000000 --- a/backend/email-templates/login-with-new-device.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - Pocket ID - - - -
-
- -
Warning
-
-
-

New Sign-In Detected

-
-
-

IP Address

-

{{ipAddress}}

-
-
-

Device

-

{{device}}

-
-
-

Sign-In Time

-

{{dateTimeString}}

-
-
-

- This sign-in was detected from a new device or location. If you recognize this activity, you can safely ignore - this message. If not, please review your account and security settings. -

-
-
- - - \ No newline at end of file diff --git a/backend/email-templates/login-with-new-device_html.tmpl b/backend/email-templates/login-with-new-device_html.tmpl new file mode 100644 index 0000000..4a6cde4 --- /dev/null +++ b/backend/email-templates/login-with-new-device_html.tmpl @@ -0,0 +1,30 @@ +{{ define "base" }} +
+ +
Warning
+
+
+

New Sign-In Detected

+
+
+

IP Address

+

{{ .Data.IPAddress}}

+
+
+

Device

+

{{ .Data.Device }}

+
+
+

Sign-In Time

+

{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}

+
+
+

+ This sign-in was detected from a new device or location. If you recognize this activity, you can + safely ignore this message. If not, please review your account and security settings. +

+
+{{ end -}} diff --git a/backend/email-templates/login-with-new-device_text.tmpl b/backend/email-templates/login-with-new-device_text.tmpl new file mode 100644 index 0000000..a89e6a0 --- /dev/null +++ b/backend/email-templates/login-with-new-device_text.tmpl @@ -0,0 +1,12 @@ +{{ define "base" -}} +New Sign-In Detected +==================== + +IP Address: {{ .Data.IPAddress }} +Device: {{ .Data.Device }} +Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}} + +This sign-in was detected from a new device or location. If you recognize +this activity, you can safely ignore this message. If not, please review +your account and security settings. +{{ end -}} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 417afd8..79ab2d9 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -2,6 +2,7 @@ package bootstrap import ( "log" + "os" "time" "github.com/gin-gonic/gin" @@ -28,7 +29,12 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { r.Use(gin.Logger()) // Initialize services - emailService := service.NewEmailService(appConfigService) + templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) + emailService, err := service.NewEmailService(appConfigService, templateDir) + if err != nil { + log.Fatalf("Unable to create email service: %s", err) + } + auditLogService := service.NewAuditLogService(db, appConfigService, emailService) jwtService := service.NewJwtService(appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 4a0f673..94f0ce4 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -7,21 +7,23 @@ import ( ) type EnvConfigSchema struct { - AppEnv string `env:"APP_ENV"` - AppURL string `env:"PUBLIC_APP_URL"` - DBPath string `env:"DB_PATH"` - UploadPath string `env:"UPLOAD_PATH"` - Port string `env:"BACKEND_PORT"` - Host string `env:"HOST"` + AppEnv string `env:"APP_ENV"` + AppURL string `env:"PUBLIC_APP_URL"` + DBPath string `env:"DB_PATH"` + UploadPath string `env:"UPLOAD_PATH"` + Port string `env:"BACKEND_PORT"` + Host string `env:"HOST"` + EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"` } var EnvConfig = &EnvConfigSchema{ - AppEnv: "production", - DBPath: "data/pocket-id.db", - UploadPath: "data/uploads", - AppURL: "http://localhost", - Port: "8080", - Host: "localhost", + AppEnv: "production", + DBPath: "data/pocket-id.db", + UploadPath: "data/uploads", + AppURL: "http://localhost", + Port: "8080", + Host: "localhost", + EmailTemplatesPath: "./email-templates", } func init() { diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index ab6adb6..0565b0c 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -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) } }() } diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 429f8cf..7e5c410 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -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 +} diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go new file mode 100644 index 0000000..3f8ca2b --- /dev/null +++ b/backend/internal/service/email_service_templates.go @@ -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} diff --git a/backend/internal/utils/email/composer.go b/backend/internal/utils/email/composer.go new file mode 100644 index 0000000..f0bec93 --- /dev/null +++ b/backend/internal/utils/email/composer.go @@ -0,0 +1,213 @@ +package email + +import ( + "fmt" + "strings" + "unicode" +) + +const maxLineLength = 78 +const continuePrefix = " " +const addressSeparator = ", " + +type Composer struct { + isClosed bool + content strings.Builder +} + +func NewComposer() *Composer { + return &Composer{} +} + +type Address struct { + Name string + Email string +} + +func (c *Composer) AddAddressHeader(name string, addresses []Address) { + c.content.WriteString(genAddressHeader(name, addresses, maxLineLength)) + c.content.WriteString("\n") +} + +func genAddressHeader(name string, addresses []Address, maxLength int) string { + hl := &headerLine{ + maxLineLength: maxLength, + continuePrefix: continuePrefix, + } + + hl.Write(name) + hl.Write(": ") + + for i, addr := range addresses { + var email string + if i < len(addresses)-1 { + email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator) + } else { + email = fmt.Sprintf("<%s>", addr.Email) + } + writeHeaderQ(hl, addr.Name) + writeHeaderAtom(hl, " ") + writeHeaderAtom(hl, email) + } + hl.EndLine() + return hl.String() +} + +func (c *Composer) AddHeader(name, value string) { + if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength { + c.AddHeaderRaw(name, value) + return + } + + c.content.WriteString(genHeader(name, value, maxLineLength)) + c.content.WriteString("\n") +} + +func genHeader(name, value string, maxLength int) string { + // add content as raw header when it is printable ASCII and shorter than maxLineLength + hl := &headerLine{ + maxLineLength: maxLength, + continuePrefix: continuePrefix, + } + + hl.Write(name) + hl.Write(": ") + writeHeaderQ(hl, value) + hl.EndLine() + return hl.String() +} + +const qEncStart = "=?utf-8?q?" +const qEncEnd = "?=" + +type headerLine struct { + buffer strings.Builder + line strings.Builder + maxLineLength int + continuePrefix string +} + +func (h *headerLine) FitsLine(length int) bool { + return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength +} + +func (h *headerLine) Write(str string) { + h.line.WriteString(str) +} + +func (h *headerLine) EndLineWith(str string) { + h.line.WriteString(str) + h.EndLine() +} + +func (h *headerLine) EndLine() { + if h.line.Len() == 0 { + return + } + + if h.buffer.Len() != 0 { + h.buffer.WriteString("\n") + h.buffer.WriteString(h.continuePrefix) + } + h.buffer.WriteString(h.line.String()) + h.line.Reset() +} + +func (h *headerLine) String() string { + return h.buffer.String() +} + +func writeHeaderQ(header *headerLine, value string) { + + // current line does not fit event the first character - do \n + if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) { + header.EndLineWith("") + } + + header.Write(qEncStart) + + for _, token := range convertRunes(value) { + if header.FitsLine(len(token) + len(qEncEnd)) { + header.Write(token) + } else { + header.EndLineWith(qEncEnd) + header.Write(qEncStart) + header.Write(token) + } + } + + header.Write(qEncEnd) +} + +func writeHeaderAtom(header *headerLine, value string) { + if !header.FitsLine(len(value)) { + header.EndLine() + } + header.Write(value) +} + +func (c *Composer) AddHeaderRaw(name, value string) { + if c.isClosed { + panic("composer had already written body!") + } + header := fmt.Sprintf("%s: %s\n", name, value) + c.content.WriteString(header) +} + +func (c *Composer) Body(body string) { + c.content.WriteString("\n") + c.content.WriteString(body) + c.isClosed = true +} + +func (c *Composer) String() string { + return c.content.String() +} + +func convertRunes(str string) []string { + var enc = make([]string, 0, len(str)) + for _, r := range []rune(str) { + if r == ' ' { + enc = append(enc, "_") + } else if isPrintableASCIIRune(r) && + r != '=' && + r != '?' && + r != '_' { + enc = append(enc, string(r)) + } else { + enc = append(enc, string(toHex([]byte(string(r))))) + } + } + return enc +} + +func toHex(in []byte) []byte { + enc := make([]byte, 0, len(in)*2) + for _, b := range in { + enc = append(enc, '=') + enc = append(enc, hex(b/16)) + enc = append(enc, hex(b%16)) + } + return enc +} + +func hex(n byte) byte { + if n > 9 { + return n + (65 - 10) + } else { + return n + 48 + } +} + +func isPrintableASCII(str string) bool { + for _, r := range []rune(str) { + if !unicode.IsPrint(r) || r >= unicode.MaxASCII { + return false + } + } + return true +} + +func isPrintableASCIIRune(r rune) bool { + return r > 31 && r < 127 +} diff --git a/backend/internal/utils/email/composer_test.go b/backend/internal/utils/email/composer_test.go new file mode 100644 index 0000000..bc26160 --- /dev/null +++ b/backend/internal/utils/email/composer_test.go @@ -0,0 +1,92 @@ +package email + +import ( + "strings" + "testing" +) + +func TestConvertRunes(t *testing.T) { + var testData = map[string]string{ + "=??=_.": "=3D=3F=3F=3D=5F.", + "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E", + } + for input, expected := range testData { + got := strings.Join(convertRunes(input), "") + if got != expected { + t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got) + } + } +} + +type genHeaderTestData struct { + name string + value string + expected string + maxWidth int +} + +func TestGenHeaderQ(t *testing.T) { + var testData = []genHeaderTestData{ + { + name: "Subject", + value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎", + expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" + + " =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" + + " =?utf-8?q?dy_=F0=9F=90=8E?=", + maxWidth: 80, + }, + } + for _, data := range testData { + got := genHeader(data.name, data.value, data.maxWidth) + if got != data.expected { + t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got) + } + + } +} + +type genAddressHeaderTestData struct { + name string + addresses []Address + expected string + maxLength int +} + +func TestGenAddressHeader(t *testing.T) { + var testData = []genAddressHeaderTestData{ + { + name: "To", + addresses: []Address{ + { + Name: "Oldřich Jánský", + Email: "olrd@example.com", + }, + }, + expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= ", + maxLength: 80, + }, + { + name: "Subject", + addresses: []Address{ + { + Name: "Oldřich Jánský", + Email: "olrd@example.com", + }, + { + Name: "Jan Novák", + Email: "novak@example.com", + }, + }, + expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= , \n" + + " =?utf-8?q?Jan_Nov=C3=A1k?= ", + maxLength: 80, + }, + } + for _, data := range testData { + got := genAddressHeader(data.name, data.addresses, data.maxLength) + if got != data.expected { + t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got) + } + + } +} diff --git a/backend/internal/utils/email/email_service_templates.go b/backend/internal/utils/email/email_service_templates.go new file mode 100644 index 0000000..f5ce4cc --- /dev/null +++ b/backend/internal/utils/email/email_service_templates.go @@ -0,0 +1,97 @@ +package email + +import ( + "fmt" + htemplate "html/template" + "io/fs" + "path" + ttemplate "text/template" +) + +const templateComponentsDir = "components" + +type Template[V any] struct { + Path string + Title func(data *TemplateData[V]) string +} + +type TemplateData[V any] struct { + AppName string + LogoURL string + Data *V +} + +type TemplateMap[V any] map[string]*V + +func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U { + return templateMap[template.Path] +} + +type clonable[V pareseable[V]] interface { + Clone() (V, error) +} + +type pareseable[V any] interface { + ParseFS(fs.FS, ...string) (V, error) +} + +func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) { + tmpl, err := rootTemplate.Clone() + if err != nil { + return *new(V), fmt.Errorf("clone root html template: %w", err) + } + + filename := fmt.Sprintf("%s%s", template, suffix) + _, err = tmpl.ParseFS(templateDir, filename) + if err != nil { + return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err) + } + + return tmpl, nil +} + +func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) { + components := path.Join(templateComponentsDir, "*_text.tmpl") + rootTmpl, err := ttemplate.ParseFS(templateDir, components) + if err != nil { + return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err) + } + + var textTemplates = make(map[string]*ttemplate.Template, len(templates)) + for _, tmpl := range templates { + rootTmplClone, err := rootTmpl.Clone() + if err != nil { + return nil, fmt.Errorf("clone root template: %w", err) + } + + textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl") + if err != nil { + return nil, fmt.Errorf("parse '%s': %w", tmpl, err) + } + } + + return textTemplates, nil +} + +func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) { + components := path.Join(templateComponentsDir, "*_html.tmpl") + rootTmpl, err := htemplate.ParseFS(templateDir, components) + if err != nil { + return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err) + } + + var htmlTemplates = make(map[string]*htemplate.Template, len(templates)) + for _, tmpl := range templates { + rootTmplClone, err := rootTmpl.Clone() + if err != nil { + return nil, fmt.Errorf("clone root template: %w", err) + } + + htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl") + if err != nil { + return nil, fmt.Errorf("parse '%s': %w", tmpl, err) + } + } + + return htmlTemplates, nil +}